solvecha 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.
- solvecha-0.1.0/.gitignore +5 -0
- solvecha-0.1.0/PKG-INFO +88 -0
- solvecha-0.1.0/README.md +63 -0
- solvecha-0.1.0/pyproject.toml +32 -0
- solvecha-0.1.0/src/solvecha/__init__.py +27 -0
- solvecha-0.1.0/src/solvecha/browser_use_action.py +48 -0
- solvecha-0.1.0/src/solvecha/client.py +185 -0
- solvecha-0.1.0/src/solvecha/core.py +71 -0
- solvecha-0.1.0/src/solvecha/errors.py +50 -0
- solvecha-0.1.0/src/solvecha/results.py +40 -0
- solvecha-0.1.0/src/solvecha/tools.py +186 -0
- solvecha-0.1.0/tests/test_client.py +141 -0
- solvecha-0.1.0/tests/test_frameworks.py +60 -0
solvecha-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: solvecha
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client and AI agent tools for the Solvecha captcha solving API
|
|
5
|
+
Project-URL: Homepage, https://solvecha.com/docs/ai-agents
|
|
6
|
+
Project-URL: Repository, https://github.com/Solvecha/solvecha-skills
|
|
7
|
+
Project-URL: Documentation, https://solvecha.com/docs
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Requires-Dist: httpx>=0.25
|
|
11
|
+
Provides-Extra: browser-use
|
|
12
|
+
Requires-Dist: browser-use; extra == 'browser-use'
|
|
13
|
+
Provides-Extra: crewai
|
|
14
|
+
Requires-Dist: crewai; extra == 'crewai'
|
|
15
|
+
Provides-Extra: langchain
|
|
16
|
+
Requires-Dist: langchain-core; extra == 'langchain'
|
|
17
|
+
Provides-Extra: llamaindex
|
|
18
|
+
Requires-Dist: llama-index-core; extra == 'llamaindex'
|
|
19
|
+
Provides-Extra: openai
|
|
20
|
+
Requires-Dist: openai-agents; extra == 'openai'
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest; extra == 'test'
|
|
23
|
+
Requires-Dist: pytest-asyncio; extra == 'test'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# solvecha (Python)
|
|
27
|
+
|
|
28
|
+
A small client for the [Solvecha](https://solvecha.com) captcha API, plus ready-made tools for AI agent frameworks: OpenAI function calling and the Agents SDK, LangChain, CrewAI, LlamaIndex and Browser Use.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install solvecha # client only (needs httpx)
|
|
32
|
+
pip install "solvecha[langchain]" # or openai, crewai, llamaindex, browser-use
|
|
33
|
+
export SOLVECHA_API_KEY=slv_... # create a key at https://solvecha.com/dashboard
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Client
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from solvecha import Solvecha
|
|
40
|
+
|
|
41
|
+
client = Solvecha() # reads SOLVECHA_API_KEY
|
|
42
|
+
result = client.solve_recaptcha("Select all images with buses", "3x3", "grid.png")
|
|
43
|
+
print(result.tiles) # [2, 4, 5], counted from 1, left to right, top to bottom
|
|
44
|
+
print(result.positions) # ['row 1, column 2', ...]
|
|
45
|
+
client.report_result(result.job_id, "accepted", "3x3") # optional, free
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`images` can be a file path, bytes, base64 text or a data URL. `AsyncSolvecha` has the same methods. Other methods: `solve_text`, `solve_geetest`, `solve_funcaptcha`, `status`. Errors raise `SolvechaError` subclasses with the API code (`InvalidKeyError` 15, `OutOfCreditError` 16, `RateLimitError` 11).
|
|
49
|
+
|
|
50
|
+
## Agent tools
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from solvecha import Solvecha
|
|
54
|
+
from solvecha.tools import openai_tools, openai_agents_tools, langchain_tools, crewai_tools, llamaindex_tools
|
|
55
|
+
|
|
56
|
+
client = Solvecha()
|
|
57
|
+
tools, handle = openai_tools(client) # Chat Completions: pass tools=tools, answer calls with handle(name, args_json)
|
|
58
|
+
agents_sdk = openai_agents_tools(client) # OpenAI Agents SDK
|
|
59
|
+
lc = langchain_tools(client) # LangChain / LangGraph
|
|
60
|
+
crew = crewai_tools(client) # CrewAI
|
|
61
|
+
li = llamaindex_tools(client) # LlamaIndex
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Tools never raise: a failure comes back as `{"error": "..."}` with advice for the model.
|
|
65
|
+
|
|
66
|
+
### Browser Use
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from browser_use import Agent, Browser, ChatBrowserUse
|
|
70
|
+
from solvecha import AsyncSolvecha
|
|
71
|
+
from solvecha.tools import browser_use_tools
|
|
72
|
+
|
|
73
|
+
tools = browser_use_tools(AsyncSolvecha()) # adds the action solve_recaptcha_in_tab
|
|
74
|
+
agent = Agent(task="...", llm=ChatBrowserUse(), browser=Browser(), tools=tools)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The action crops the screenshot to the reCAPTCHA challenge window and returns the tiles to click.
|
|
78
|
+
|
|
79
|
+
## Or use MCP
|
|
80
|
+
|
|
81
|
+
No code needed: `https://api.solvecha.com/mcp` with `Authorization: Bearer <key>`. See https://solvecha.com/docs/mcp-server.
|
|
82
|
+
|
|
83
|
+
## Develop
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pip install -e ".[test]" && pytest
|
|
87
|
+
python -m build # needs: pip install build
|
|
88
|
+
```
|
solvecha-0.1.0/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# solvecha (Python)
|
|
2
|
+
|
|
3
|
+
A small client for the [Solvecha](https://solvecha.com) captcha API, plus ready-made tools for AI agent frameworks: OpenAI function calling and the Agents SDK, LangChain, CrewAI, LlamaIndex and Browser Use.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install solvecha # client only (needs httpx)
|
|
7
|
+
pip install "solvecha[langchain]" # or openai, crewai, llamaindex, browser-use
|
|
8
|
+
export SOLVECHA_API_KEY=slv_... # create a key at https://solvecha.com/dashboard
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Client
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from solvecha import Solvecha
|
|
15
|
+
|
|
16
|
+
client = Solvecha() # reads SOLVECHA_API_KEY
|
|
17
|
+
result = client.solve_recaptcha("Select all images with buses", "3x3", "grid.png")
|
|
18
|
+
print(result.tiles) # [2, 4, 5], counted from 1, left to right, top to bottom
|
|
19
|
+
print(result.positions) # ['row 1, column 2', ...]
|
|
20
|
+
client.report_result(result.job_id, "accepted", "3x3") # optional, free
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`images` can be a file path, bytes, base64 text or a data URL. `AsyncSolvecha` has the same methods. Other methods: `solve_text`, `solve_geetest`, `solve_funcaptcha`, `status`. Errors raise `SolvechaError` subclasses with the API code (`InvalidKeyError` 15, `OutOfCreditError` 16, `RateLimitError` 11).
|
|
24
|
+
|
|
25
|
+
## Agent tools
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from solvecha import Solvecha
|
|
29
|
+
from solvecha.tools import openai_tools, openai_agents_tools, langchain_tools, crewai_tools, llamaindex_tools
|
|
30
|
+
|
|
31
|
+
client = Solvecha()
|
|
32
|
+
tools, handle = openai_tools(client) # Chat Completions: pass tools=tools, answer calls with handle(name, args_json)
|
|
33
|
+
agents_sdk = openai_agents_tools(client) # OpenAI Agents SDK
|
|
34
|
+
lc = langchain_tools(client) # LangChain / LangGraph
|
|
35
|
+
crew = crewai_tools(client) # CrewAI
|
|
36
|
+
li = llamaindex_tools(client) # LlamaIndex
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Tools never raise: a failure comes back as `{"error": "..."}` with advice for the model.
|
|
40
|
+
|
|
41
|
+
### Browser Use
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from browser_use import Agent, Browser, ChatBrowserUse
|
|
45
|
+
from solvecha import AsyncSolvecha
|
|
46
|
+
from solvecha.tools import browser_use_tools
|
|
47
|
+
|
|
48
|
+
tools = browser_use_tools(AsyncSolvecha()) # adds the action solve_recaptcha_in_tab
|
|
49
|
+
agent = Agent(task="...", llm=ChatBrowserUse(), browser=Browser(), tools=tools)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The action crops the screenshot to the reCAPTCHA challenge window and returns the tiles to click.
|
|
53
|
+
|
|
54
|
+
## Or use MCP
|
|
55
|
+
|
|
56
|
+
No code needed: `https://api.solvecha.com/mcp` with `Authorization: Bearer <key>`. See https://solvecha.com/docs/mcp-server.
|
|
57
|
+
|
|
58
|
+
## Develop
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install -e ".[test]" && pytest
|
|
62
|
+
python -m build # needs: pip install build
|
|
63
|
+
```
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "solvecha"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client and AI agent tools for the Solvecha captcha solving API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = ["httpx>=0.25"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
openai = ["openai-agents"]
|
|
16
|
+
langchain = ["langchain-core"]
|
|
17
|
+
crewai = ["crewai"]
|
|
18
|
+
llamaindex = ["llama-index-core"]
|
|
19
|
+
browser-use = ["browser-use"]
|
|
20
|
+
test = ["pytest", "pytest-asyncio"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://solvecha.com/docs/ai-agents"
|
|
24
|
+
Repository = "https://github.com/Solvecha/solvecha-skills"
|
|
25
|
+
Documentation = "https://solvecha.com/docs"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/solvecha"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
asyncio_mode = "auto"
|
|
32
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Solvecha: captcha solving for scripts and AI agents."""
|
|
2
|
+
|
|
3
|
+
from .client import AsyncSolvecha, Solvecha
|
|
4
|
+
from .errors import (
|
|
5
|
+
InvalidKeyError,
|
|
6
|
+
InvalidRequestError,
|
|
7
|
+
OutOfCreditError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
SolvechaError,
|
|
10
|
+
SolveTimeoutError,
|
|
11
|
+
)
|
|
12
|
+
from .results import GeetestResult, GridResult, TextResult
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AsyncSolvecha",
|
|
16
|
+
"Solvecha",
|
|
17
|
+
"GridResult",
|
|
18
|
+
"TextResult",
|
|
19
|
+
"GeetestResult",
|
|
20
|
+
"SolvechaError",
|
|
21
|
+
"InvalidKeyError",
|
|
22
|
+
"InvalidRequestError",
|
|
23
|
+
"OutOfCreditError",
|
|
24
|
+
"RateLimitError",
|
|
25
|
+
"SolveTimeoutError",
|
|
26
|
+
]
|
|
27
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# No "from __future__ import annotations" here: Browser Use reads the real
|
|
2
|
+
# annotation objects to inject its browser_session argument.
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from browser_use import ActionResult, BrowserSession, Tools
|
|
7
|
+
|
|
8
|
+
from .client import AsyncSolvecha
|
|
9
|
+
from .errors import SolvechaError
|
|
10
|
+
|
|
11
|
+
FIND_CHALLENGE = """(() => {
|
|
12
|
+
const f = document.querySelector('iframe[src*="recaptcha/api2/bframe"], iframe[src*="recaptcha/enterprise/bframe"]');
|
|
13
|
+
if (!f) return null;
|
|
14
|
+
const r = f.getBoundingClientRect();
|
|
15
|
+
return r.width > 50 && r.height > 50 ? JSON.stringify({x: r.x, y: r.y, width: r.width, height: r.height}) : null;
|
|
16
|
+
})()"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(client: AsyncSolvecha, tools: Optional[Any] = None) -> Any:
|
|
20
|
+
tools = tools or Tools()
|
|
21
|
+
|
|
22
|
+
@tools.action(
|
|
23
|
+
description=(
|
|
24
|
+
"Solve the reCAPTCHA v2 image challenge that is open in the current tab. Pass the instruction text "
|
|
25
|
+
"(e.g. 'Select all images with buses') and the grid ('3x3' or '4x4'). Returns the tile numbers to click, "
|
|
26
|
+
"counted from 1, left to right, top to bottom, and a job id."
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
async def solve_recaptcha_in_tab(task: str, grid: str, browser_session: BrowserSession) -> ActionResult:
|
|
30
|
+
clip = None
|
|
31
|
+
try:
|
|
32
|
+
page = await browser_session.must_get_current_page()
|
|
33
|
+
raw = await page.evaluate(FIND_CHALLENGE)
|
|
34
|
+
if isinstance(raw, str) and raw not in ("", "null"):
|
|
35
|
+
box = json.loads(raw)
|
|
36
|
+
clip = {"x": box["x"], "y": box["y"], "width": box["width"], "height": box["height"], "scale": 1}
|
|
37
|
+
except Exception: # noqa: BLE001 - cropping is optional
|
|
38
|
+
clip = None
|
|
39
|
+
png = await browser_session.take_screenshot(clip=clip) if clip else await browser_session.take_screenshot()
|
|
40
|
+
try:
|
|
41
|
+
result = await client.solve_recaptcha(task, grid, png)
|
|
42
|
+
except (SolvechaError, ValueError) as error:
|
|
43
|
+
return ActionResult(error=str(error))
|
|
44
|
+
return ActionResult(
|
|
45
|
+
extracted_content=f"Click tiles {result.tiles} ({'; '.join(result.positions)}), then press Verify. job_id={result.job_id}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return tools
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Dict, Optional, Sequence, Union
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from . import core
|
|
10
|
+
from .core import Image
|
|
11
|
+
from .errors import SolveTimeoutError, error_for
|
|
12
|
+
from .results import GeetestResult, GridResult, TextResult
|
|
13
|
+
|
|
14
|
+
DEFAULT_TIMEOUT = 45.0
|
|
15
|
+
POLL_INTERVAL = 1.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _body(response: httpx.Response) -> Dict[str, Any]:
|
|
19
|
+
try:
|
|
20
|
+
parsed = response.json()
|
|
21
|
+
except ValueError:
|
|
22
|
+
return {"error": 9, "message": f"HTTP {response.status_code}"}
|
|
23
|
+
return parsed if isinstance(parsed, dict) else {"data": parsed}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Solvecha:
|
|
27
|
+
"""Synchronous client. ``api_key`` defaults to the SOLVECHA_API_KEY environment variable."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
api_key: Optional[str] = None,
|
|
32
|
+
base_url: str = core.DEFAULT_BASE_URL,
|
|
33
|
+
http: Optional[httpx.Client] = None,
|
|
34
|
+
poll_interval: float = POLL_INTERVAL,
|
|
35
|
+
):
|
|
36
|
+
self._key = core.resolve_key(api_key)
|
|
37
|
+
self._poll = poll_interval
|
|
38
|
+
self._http = http or httpx.Client(timeout=30)
|
|
39
|
+
self._base = base_url.rstrip("/")
|
|
40
|
+
|
|
41
|
+
def _request(self, method: str, path: str, json: Any = None) -> Dict[str, Any]:
|
|
42
|
+
response = self._http.request(method, self._base + path, json=json, headers=core.headers(self._key))
|
|
43
|
+
return _body(response)
|
|
44
|
+
|
|
45
|
+
def solve(self, kind: str, payload: Dict[str, Any], timeout: float = DEFAULT_TIMEOUT) -> tuple:
|
|
46
|
+
"""Create a job and wait for the answer. Returns (job_id, data)."""
|
|
47
|
+
job_id = core.job_id_from(self._request("POST", f"/v1/recognition/{kind}", payload))
|
|
48
|
+
deadline = time.monotonic() + timeout
|
|
49
|
+
while True:
|
|
50
|
+
time.sleep(self._poll)
|
|
51
|
+
answer = self._request("GET", f"/v1/recognition/{kind}?id={job_id}")
|
|
52
|
+
code = core.error_code(answer)
|
|
53
|
+
if code == 0 and "data" in answer:
|
|
54
|
+
return job_id, answer["data"]
|
|
55
|
+
if code == 14:
|
|
56
|
+
pass
|
|
57
|
+
elif code == 11:
|
|
58
|
+
time.sleep(2)
|
|
59
|
+
else:
|
|
60
|
+
raise error_for(code or 9, str(answer.get("message", "")))
|
|
61
|
+
if time.monotonic() >= deadline:
|
|
62
|
+
raise SolveTimeoutError(job_id, timeout)
|
|
63
|
+
|
|
64
|
+
def solve_recaptcha(
|
|
65
|
+
self, task: str, grid: str, images: Union[Image, Sequence[Image]], timeout: float = DEFAULT_TIMEOUT
|
|
66
|
+
) -> GridResult:
|
|
67
|
+
pictures = core.images_payload(images)
|
|
68
|
+
core.check_grid(grid, len(pictures))
|
|
69
|
+
job_id, data = self.solve("recaptcha", {"task": task, "grid": grid, "image_data": pictures}, timeout)
|
|
70
|
+
return GridResult(job_id, grid, list(data))
|
|
71
|
+
|
|
72
|
+
def solve_text(self, image: Image, timeout: float = DEFAULT_TIMEOUT) -> TextResult:
|
|
73
|
+
job_id, data = self.solve("textcaptcha", {"image_data": core.images_payload(image)}, timeout)
|
|
74
|
+
return TextResult(job_id, data[0])
|
|
75
|
+
|
|
76
|
+
def solve_geetest(
|
|
77
|
+
self, images: Union[Image, Sequence[Image]], task: str = "slide", timeout: float = DEFAULT_TIMEOUT
|
|
78
|
+
) -> GeetestResult:
|
|
79
|
+
job_id, data = self.solve("geetest", {"task": task, "image_data": core.images_payload(images)}, timeout)
|
|
80
|
+
return GeetestResult(job_id, float(data["x"]))
|
|
81
|
+
|
|
82
|
+
def solve_funcaptcha(self, task: str, image: Image, match: bool = False, timeout: float = DEFAULT_TIMEOUT) -> GridResult:
|
|
83
|
+
kind = "funcaptcha_match" if match else "funcaptcha"
|
|
84
|
+
job_id, data = self.solve(kind, {"task": task, "image_data": core.images_payload(image)}, timeout)
|
|
85
|
+
return GridResult(job_id, "funcaptcha", list(data))
|
|
86
|
+
|
|
87
|
+
def status(self) -> Dict[str, Any]:
|
|
88
|
+
body = self._request("GET", "/v1/status")
|
|
89
|
+
if core.error_code(body):
|
|
90
|
+
raise error_for(core.error_code(body), str(body.get("message", "")))
|
|
91
|
+
return body
|
|
92
|
+
|
|
93
|
+
def report_result(self, job_id: str, verdict: str, grid: str, round_index: Optional[int] = None) -> bool:
|
|
94
|
+
payload: Dict[str, Any] = {"job_id": job_id, "verdict": verdict, "grid": grid}
|
|
95
|
+
if round_index is not None:
|
|
96
|
+
payload["round_index"] = round_index
|
|
97
|
+
body = self._request("POST", "/v1/feedback", payload)
|
|
98
|
+
if core.error_code(body):
|
|
99
|
+
raise error_for(core.error_code(body), str(body.get("message", "")))
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
def close(self) -> None:
|
|
103
|
+
self._http.close()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AsyncSolvecha:
|
|
107
|
+
"""Asynchronous client with the same methods as :class:`Solvecha`."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
api_key: Optional[str] = None,
|
|
112
|
+
base_url: str = core.DEFAULT_BASE_URL,
|
|
113
|
+
http: Optional[httpx.AsyncClient] = None,
|
|
114
|
+
poll_interval: float = POLL_INTERVAL,
|
|
115
|
+
):
|
|
116
|
+
self._key = core.resolve_key(api_key)
|
|
117
|
+
self._poll = poll_interval
|
|
118
|
+
self._http = http or httpx.AsyncClient(timeout=30)
|
|
119
|
+
self._base = base_url.rstrip("/")
|
|
120
|
+
|
|
121
|
+
async def _request(self, method: str, path: str, json: Any = None) -> Dict[str, Any]:
|
|
122
|
+
response = await self._http.request(method, self._base + path, json=json, headers=core.headers(self._key))
|
|
123
|
+
return _body(response)
|
|
124
|
+
|
|
125
|
+
async def solve(self, kind: str, payload: Dict[str, Any], timeout: float = DEFAULT_TIMEOUT) -> tuple:
|
|
126
|
+
job_id = core.job_id_from(await self._request("POST", f"/v1/recognition/{kind}", payload))
|
|
127
|
+
loop = asyncio.get_running_loop()
|
|
128
|
+
deadline = loop.time() + timeout
|
|
129
|
+
while True:
|
|
130
|
+
await asyncio.sleep(self._poll)
|
|
131
|
+
answer = await self._request("GET", f"/v1/recognition/{kind}?id={job_id}")
|
|
132
|
+
code = core.error_code(answer)
|
|
133
|
+
if code == 0 and "data" in answer:
|
|
134
|
+
return job_id, answer["data"]
|
|
135
|
+
if code == 14:
|
|
136
|
+
pass
|
|
137
|
+
elif code == 11:
|
|
138
|
+
await asyncio.sleep(2)
|
|
139
|
+
else:
|
|
140
|
+
raise error_for(code or 9, str(answer.get("message", "")))
|
|
141
|
+
if loop.time() >= deadline:
|
|
142
|
+
raise SolveTimeoutError(job_id, timeout)
|
|
143
|
+
|
|
144
|
+
async def solve_recaptcha(
|
|
145
|
+
self, task: str, grid: str, images: Union[Image, Sequence[Image]], timeout: float = DEFAULT_TIMEOUT
|
|
146
|
+
) -> GridResult:
|
|
147
|
+
pictures = core.images_payload(images)
|
|
148
|
+
core.check_grid(grid, len(pictures))
|
|
149
|
+
job_id, data = await self.solve("recaptcha", {"task": task, "grid": grid, "image_data": pictures}, timeout)
|
|
150
|
+
return GridResult(job_id, grid, list(data))
|
|
151
|
+
|
|
152
|
+
async def solve_text(self, image: Image, timeout: float = DEFAULT_TIMEOUT) -> TextResult:
|
|
153
|
+
job_id, data = await self.solve("textcaptcha", {"image_data": core.images_payload(image)}, timeout)
|
|
154
|
+
return TextResult(job_id, data[0])
|
|
155
|
+
|
|
156
|
+
async def solve_geetest(
|
|
157
|
+
self, images: Union[Image, Sequence[Image]], task: str = "slide", timeout: float = DEFAULT_TIMEOUT
|
|
158
|
+
) -> GeetestResult:
|
|
159
|
+
job_id, data = await self.solve("geetest", {"task": task, "image_data": core.images_payload(images)}, timeout)
|
|
160
|
+
return GeetestResult(job_id, float(data["x"]))
|
|
161
|
+
|
|
162
|
+
async def solve_funcaptcha(
|
|
163
|
+
self, task: str, image: Image, match: bool = False, timeout: float = DEFAULT_TIMEOUT
|
|
164
|
+
) -> GridResult:
|
|
165
|
+
kind = "funcaptcha_match" if match else "funcaptcha"
|
|
166
|
+
job_id, data = await self.solve(kind, {"task": task, "image_data": core.images_payload(image)}, timeout)
|
|
167
|
+
return GridResult(job_id, "funcaptcha", list(data))
|
|
168
|
+
|
|
169
|
+
async def status(self) -> Dict[str, Any]:
|
|
170
|
+
body = await self._request("GET", "/v1/status")
|
|
171
|
+
if core.error_code(body):
|
|
172
|
+
raise error_for(core.error_code(body), str(body.get("message", "")))
|
|
173
|
+
return body
|
|
174
|
+
|
|
175
|
+
async def report_result(self, job_id: str, verdict: str, grid: str, round_index: Optional[int] = None) -> bool:
|
|
176
|
+
payload: Dict[str, Any] = {"job_id": job_id, "verdict": verdict, "grid": grid}
|
|
177
|
+
if round_index is not None:
|
|
178
|
+
payload["round_index"] = round_index
|
|
179
|
+
body = await self._request("POST", "/v1/feedback", payload)
|
|
180
|
+
if core.error_code(body):
|
|
181
|
+
raise error_for(core.error_code(body), str(body.get("message", "")))
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
async def aclose(self) -> None:
|
|
185
|
+
await self._http.aclose()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Pure helpers shared by the sync and the async client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Dict, List, Optional, Sequence, Union
|
|
7
|
+
|
|
8
|
+
from .errors import SolvechaError, error_for
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_URL = "https://api.solvecha.com"
|
|
11
|
+
GRIDS = ("3x3", "4x4", "1x1")
|
|
12
|
+
Image = Union[str, bytes, "os.PathLike[str]"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def to_base64(image: Image) -> str:
|
|
16
|
+
"""Accept a file path, raw bytes, base64 text or a data: URL and return plain base64."""
|
|
17
|
+
if isinstance(image, (bytes, bytearray)):
|
|
18
|
+
return base64.b64encode(bytes(image)).decode()
|
|
19
|
+
text = os.fspath(image)
|
|
20
|
+
if text.startswith("data:"):
|
|
21
|
+
return text.split(",", 1)[1]
|
|
22
|
+
if len(text) < 1024 and os.path.isfile(text):
|
|
23
|
+
with open(text, "rb") as handle:
|
|
24
|
+
return base64.b64encode(handle.read()).decode()
|
|
25
|
+
return "".join(text.split())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def images_payload(images: Union[Image, Sequence[Image]]) -> List[str]:
|
|
29
|
+
if isinstance(images, (str, bytes, bytearray)) or hasattr(images, "__fspath__"):
|
|
30
|
+
images = [images] # type: ignore[list-item]
|
|
31
|
+
return [to_base64(i) for i in images] # type: ignore[union-attr]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check_grid(grid: str, count: int) -> None:
|
|
35
|
+
if grid not in GRIDS:
|
|
36
|
+
raise ValueError(f"grid must be one of {GRIDS}, got {grid!r}")
|
|
37
|
+
if grid != "1x1" and count != 1:
|
|
38
|
+
raise ValueError(f"grid {grid} needs exactly one picture of the whole grid, got {count}")
|
|
39
|
+
if grid == "1x1" and not 1 <= count <= 9:
|
|
40
|
+
raise ValueError("grid 1x1 needs 1 to 9 tile pictures")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def error_code(body: Dict[str, Any]) -> int:
|
|
44
|
+
value = body.get("error")
|
|
45
|
+
if value is None:
|
|
46
|
+
return 0
|
|
47
|
+
try:
|
|
48
|
+
return int(value)
|
|
49
|
+
except (TypeError, ValueError):
|
|
50
|
+
return 9
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def job_id_from(body: Dict[str, Any]) -> str:
|
|
54
|
+
code = error_code(body)
|
|
55
|
+
if code:
|
|
56
|
+
raise error_for(code, str(body.get("message", "")))
|
|
57
|
+
data = body.get("data")
|
|
58
|
+
if not isinstance(data, str) or not data:
|
|
59
|
+
raise SolvechaError(9, "no job id in the answer")
|
|
60
|
+
return data
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def headers(api_key: str) -> Dict[str, str]:
|
|
64
|
+
return {"Authorization": f"Bearer {api_key}", "User-Agent": "solvecha-python/0.1"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_key(api_key: Optional[str]) -> str:
|
|
68
|
+
key = api_key or os.environ.get("SOLVECHA_API_KEY", "")
|
|
69
|
+
if not key:
|
|
70
|
+
raise error_for(15, "no key")
|
|
71
|
+
return key
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
KEY_HELP = "Create a key at https://solvecha.com/dashboard and set SOLVECHA_API_KEY."
|
|
4
|
+
|
|
5
|
+
_ADVICE = {
|
|
6
|
+
9: "Temporary error, or the image could not be solved. Retry once with a fresh image. You are not charged.",
|
|
7
|
+
10: "Invalid request. Check the task text, the grid and the images.",
|
|
8
|
+
11: "Rate limit reached. Wait 10 to 60 seconds and try again.",
|
|
9
|
+
15: "The API key is missing or wrong. " + KEY_HELP,
|
|
10
|
+
16: "Out of credit. Top up the balance or pick a plan at https://solvecha.com/dashboard. Do not retry.",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SolvechaError(Exception):
|
|
15
|
+
"""An error answer of the Solvecha API. ``code`` is the API error code."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, code: int, message: str = ""):
|
|
18
|
+
self.code = code
|
|
19
|
+
self.api_message = message
|
|
20
|
+
super().__init__(f"Solvecha error {code}: {_ADVICE.get(code, message or 'unexpected answer')}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InvalidRequestError(SolvechaError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RateLimitError(SolvechaError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InvalidKeyError(SolvechaError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OutOfCreditError(SolvechaError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SolveTimeoutError(SolvechaError, TimeoutError):
|
|
40
|
+
def __init__(self, job_id: str, seconds: float):
|
|
41
|
+
self.job_id = job_id
|
|
42
|
+
SolvechaError.__init__(self, 14, "")
|
|
43
|
+
self.args = (f"No answer within {seconds:g} seconds (job {job_id}). You were not charged.",)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_BY_CODE = {10: InvalidRequestError, 11: RateLimitError, 15: InvalidKeyError, 16: OutOfCreditError}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def error_for(code: int, message: str = "") -> SolvechaError:
|
|
50
|
+
return _BY_CODE.get(code, SolvechaError)(code, message)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
_COLUMNS = {"3x3": 3, "4x4": 4}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class GridResult:
|
|
11
|
+
"""Answer for an image grid. ``tiles`` are counted from 1, left to right, top to bottom."""
|
|
12
|
+
|
|
13
|
+
job_id: str
|
|
14
|
+
grid: str
|
|
15
|
+
answer: List[bool]
|
|
16
|
+
tiles: List[int] = field(init=False)
|
|
17
|
+
|
|
18
|
+
def __post_init__(self) -> None:
|
|
19
|
+
self.tiles = [i + 1 for i, picked in enumerate(self.answer) if picked]
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def positions(self) -> List[str]:
|
|
23
|
+
columns = _COLUMNS.get(self.grid)
|
|
24
|
+
if not columns:
|
|
25
|
+
return []
|
|
26
|
+
return [f"row {(t - 1) // columns + 1}, column {(t - 1) % columns + 1}" for t in self.tiles]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class TextResult:
|
|
31
|
+
job_id: str
|
|
32
|
+
text: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class GeetestResult:
|
|
37
|
+
"""``offset_percent`` is the drag distance as a percentage of the background width."""
|
|
38
|
+
|
|
39
|
+
job_id: str
|
|
40
|
+
offset_percent: float
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Ready-made agent tools for Solvecha.
|
|
2
|
+
|
|
3
|
+
Every function returns a plain dict. Errors never raise inside a tool: the dict
|
|
4
|
+
holds an ``error`` text that tells the model what to do next.
|
|
5
|
+
|
|
6
|
+
from solvecha import Solvecha
|
|
7
|
+
from solvecha.tools import langchain_tools
|
|
8
|
+
tools = langchain_tools(Solvecha())
|
|
9
|
+
|
|
10
|
+
The framework packages are imported only when you call its function.
|
|
11
|
+
"""
|
|
12
|
+
import functools
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from .client import AsyncSolvecha, Solvecha
|
|
17
|
+
from .errors import SolvechaError
|
|
18
|
+
|
|
19
|
+
Func = Callable[..., Dict[str, Any]]
|
|
20
|
+
|
|
21
|
+
TOOL_SPECS: List[Dict[str, Any]] = [
|
|
22
|
+
{
|
|
23
|
+
"name": "solve_recaptcha_grid",
|
|
24
|
+
"description": (
|
|
25
|
+
"Solve a reCAPTCHA v2 image challenge from a picture of the grid. Returns selected_tiles: the tiles "
|
|
26
|
+
"to click, counted from 1, left to right, top to bottom. Costs one credit per successful solve."
|
|
27
|
+
),
|
|
28
|
+
"parameters": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"properties": {
|
|
31
|
+
"task": {"type": "string", "description": "The instruction text, e.g. 'Select all images with buses'."},
|
|
32
|
+
"grid": {"type": "string", "enum": ["3x3", "4x4", "1x1"], "description": "Layout of the challenge."},
|
|
33
|
+
"image": {"type": "string", "description": "Path of a PNG/JPEG file, or base64 of the grid picture."},
|
|
34
|
+
},
|
|
35
|
+
"required": ["task", "grid", "image"],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "solve_text_captcha",
|
|
40
|
+
"description": "Read a picture of distorted text. Returns the case-sensitive text. Costs one credit per successful solve.",
|
|
41
|
+
"parameters": {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"properties": {"image": {"type": "string", "description": "Path of a picture file, or base64."}},
|
|
44
|
+
"required": ["image"],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "get_account_status",
|
|
49
|
+
"description": "Show the plan, the solves left today and the balance. Free.",
|
|
50
|
+
"parameters": {"type": "object", "properties": {}},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "report_result",
|
|
54
|
+
"description": "Tell Solvecha whether a reCAPTCHA answer was accepted or rejected. Free and optional.",
|
|
55
|
+
"parameters": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"properties": {
|
|
58
|
+
"job_id": {"type": "string"},
|
|
59
|
+
"verdict": {"type": "string", "enum": ["accepted", "rejected"]},
|
|
60
|
+
"grid": {"type": "string", "enum": ["3x3", "4x4", "1x1"]},
|
|
61
|
+
},
|
|
62
|
+
"required": ["job_id", "verdict", "grid"],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _guard(fn: Func) -> Func:
|
|
69
|
+
@functools.wraps(fn)
|
|
70
|
+
def wrapper(*args: Any, **kwargs: Any) -> Dict[str, Any]:
|
|
71
|
+
try:
|
|
72
|
+
return fn(*args, **kwargs)
|
|
73
|
+
except (SolvechaError, ValueError, OSError) as error:
|
|
74
|
+
return {"error": str(error)}
|
|
75
|
+
|
|
76
|
+
return wrapper
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def functions(client: Solvecha) -> Dict[str, Func]:
|
|
80
|
+
"""The tools as plain Python functions (with type hints and docstrings)."""
|
|
81
|
+
|
|
82
|
+
@_guard
|
|
83
|
+
def solve_recaptcha_grid(task: str, grid: str, image: str) -> Dict[str, Any]:
|
|
84
|
+
"""Solve a reCAPTCHA v2 image challenge. Returns selected_tiles: tiles to click, counted from 1, left to right, top to bottom.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
task: The instruction text, e.g. 'Select all images with buses'.
|
|
88
|
+
grid: '3x3', '4x4' or '1x1'.
|
|
89
|
+
image: Path of a PNG/JPEG file, or base64 of the grid picture.
|
|
90
|
+
"""
|
|
91
|
+
result = client.solve_recaptcha(task, grid, image)
|
|
92
|
+
return {"job_id": result.job_id, "selected_tiles": result.tiles, "positions": result.positions}
|
|
93
|
+
|
|
94
|
+
@_guard
|
|
95
|
+
def solve_text_captcha(image: str) -> Dict[str, Any]:
|
|
96
|
+
"""Read a picture of distorted text and return the case-sensitive text.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
image: Path of a picture file, or base64.
|
|
100
|
+
"""
|
|
101
|
+
result = client.solve_text(image)
|
|
102
|
+
return {"job_id": result.job_id, "text": result.text}
|
|
103
|
+
|
|
104
|
+
@_guard
|
|
105
|
+
def get_account_status() -> Dict[str, Any]:
|
|
106
|
+
"""Show the plan, the solves left today and the balance. Free."""
|
|
107
|
+
body = client.status()
|
|
108
|
+
return {k: body.get(k) for k in ("plan", "status", "credit", "quota", "balance_usd", "ttl")}
|
|
109
|
+
|
|
110
|
+
@_guard
|
|
111
|
+
def report_result(job_id: str, verdict: str, grid: str) -> Dict[str, Any]:
|
|
112
|
+
"""Tell Solvecha whether a reCAPTCHA answer was accepted or rejected. Free.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
job_id: The job_id from solve_recaptcha_grid.
|
|
116
|
+
verdict: 'accepted' or 'rejected'.
|
|
117
|
+
grid: '3x3', '4x4' or '1x1'.
|
|
118
|
+
"""
|
|
119
|
+
return {"recorded": client.report_result(job_id, verdict, grid)}
|
|
120
|
+
|
|
121
|
+
return {f.__name__: f for f in (solve_recaptcha_grid, solve_text_captcha, get_account_status, report_result)}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---- OpenAI function calling (Chat Completions and Responses) ----
|
|
125
|
+
|
|
126
|
+
def openai_tools(client: Solvecha) -> tuple:
|
|
127
|
+
"""Return (tools, handle). Pass ``tools`` to the API; call ``handle(name, arguments_json)`` for each tool call.
|
|
128
|
+
|
|
129
|
+
``tools`` uses the Chat Completions shape. For the Responses API flatten it:
|
|
130
|
+
``[{"type": "function", **t["function"]} for t in tools]``.
|
|
131
|
+
"""
|
|
132
|
+
funcs = functions(client)
|
|
133
|
+
tools = [{"type": "function", "function": dict(spec)} for spec in TOOL_SPECS]
|
|
134
|
+
|
|
135
|
+
def handle(name: str, arguments: str) -> str:
|
|
136
|
+
if name not in funcs:
|
|
137
|
+
return json.dumps({"error": f"unknown tool {name}"})
|
|
138
|
+
try:
|
|
139
|
+
kwargs = json.loads(arguments or "{}")
|
|
140
|
+
except ValueError:
|
|
141
|
+
return json.dumps({"error": "arguments are not valid JSON"})
|
|
142
|
+
try:
|
|
143
|
+
return json.dumps(funcs[name](**kwargs))
|
|
144
|
+
except TypeError as error:
|
|
145
|
+
return json.dumps({"error": f"bad arguments: {error}"})
|
|
146
|
+
|
|
147
|
+
return tools, handle
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def openai_agents_tools(client: Solvecha) -> list:
|
|
151
|
+
"""Tools for the OpenAI Agents SDK (``pip install openai-agents``)."""
|
|
152
|
+
from agents import function_tool
|
|
153
|
+
|
|
154
|
+
return [function_tool(f) for f in functions(client).values()]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def langchain_tools(client: Solvecha) -> list:
|
|
158
|
+
"""Tools for LangChain and LangGraph (``pip install langchain-core``)."""
|
|
159
|
+
from langchain_core.tools import StructuredTool
|
|
160
|
+
|
|
161
|
+
return [StructuredTool.from_function(f, name=f.__name__, description=(f.__doc__ or "").split("\n\n")[0]) for f in functions(client).values()]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def crewai_tools(client: Solvecha) -> list:
|
|
165
|
+
"""Tools for CrewAI (``pip install crewai``)."""
|
|
166
|
+
from crewai.tools import tool
|
|
167
|
+
|
|
168
|
+
return [tool(f.__name__)(f) for f in functions(client).values()]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def llamaindex_tools(client: Solvecha) -> list:
|
|
172
|
+
"""Tools for LlamaIndex (``pip install llama-index-core``)."""
|
|
173
|
+
from llama_index.core.tools import FunctionTool
|
|
174
|
+
|
|
175
|
+
return [FunctionTool.from_defaults(fn=f) for f in functions(client).values()]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def browser_use_tools(client: AsyncSolvecha, tools: Optional[Any] = None) -> Any:
|
|
179
|
+
"""Add a "solve the reCAPTCHA in the current tab" action to a Browser Use ``Tools`` object.
|
|
180
|
+
|
|
181
|
+
The action screenshots the reCAPTCHA challenge window (or the whole viewport when
|
|
182
|
+
none is found), solves it and returns the tiles to click. The agent clicks them.
|
|
183
|
+
"""
|
|
184
|
+
from .browser_use_action import register
|
|
185
|
+
|
|
186
|
+
return register(client, tools)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from solvecha import (
|
|
8
|
+
AsyncSolvecha,
|
|
9
|
+
InvalidKeyError,
|
|
10
|
+
OutOfCreditError,
|
|
11
|
+
Solvecha,
|
|
12
|
+
SolveTimeoutError,
|
|
13
|
+
)
|
|
14
|
+
from solvecha.tools import functions, openai_tools
|
|
15
|
+
|
|
16
|
+
PNG = base64.b64encode(b"\x89PNG\r\n\x1a\nxxxx").decode()
|
|
17
|
+
KEY = "slv_" + "a" * 32
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FakeAPI:
|
|
21
|
+
"""Imitates the Solvecha API. ``polls_before`` answers are 'incomplete job'."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, polls_before=1, answer=None, create_error=None):
|
|
24
|
+
self.requests = []
|
|
25
|
+
self.polls = 0
|
|
26
|
+
self.polls_before = polls_before
|
|
27
|
+
self.answer = answer if answer is not None else [False, True, False, True, True, False, False, False, False]
|
|
28
|
+
self.create_error = create_error
|
|
29
|
+
|
|
30
|
+
def __call__(self, request: httpx.Request) -> httpx.Response:
|
|
31
|
+
body = json.loads(request.content) if request.content else None
|
|
32
|
+
self.requests.append((request.method, request.url.path, request.url.query.decode(), body, request.headers.get("authorization")))
|
|
33
|
+
if request.headers.get("authorization") != f"Bearer {KEY}":
|
|
34
|
+
return httpx.Response(401, json={"error": 15, "message": "Invalid key"})
|
|
35
|
+
if request.url.path == "/v1/status":
|
|
36
|
+
return httpx.Response(200, json={"plan": "Free", "status": "Active", "credit": 99, "quota": 100, "balance_usd": "0.00", "ttl": 10})
|
|
37
|
+
if request.url.path == "/v1/feedback":
|
|
38
|
+
return httpx.Response(200, json={"data": "ok"})
|
|
39
|
+
if request.method == "POST":
|
|
40
|
+
if self.create_error:
|
|
41
|
+
return httpx.Response(403, json={"error": self.create_error, "message": "x"})
|
|
42
|
+
return httpx.Response(200, json={"data": "job-1"})
|
|
43
|
+
self.polls += 1
|
|
44
|
+
if self.polls <= self.polls_before:
|
|
45
|
+
return httpx.Response(409, json={"error": 14, "message": "Incomplete job"})
|
|
46
|
+
return httpx.Response(200, json={"data": self.answer})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def client(fake, **kw):
|
|
50
|
+
return Solvecha(KEY, http=httpx.Client(transport=httpx.MockTransport(fake)), poll_interval=0, **kw)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_solve_recaptcha_returns_tiles_and_never_sends_the_key_in_the_body():
|
|
54
|
+
fake = FakeAPI()
|
|
55
|
+
result = client(fake).solve_recaptcha("Select all images with buses", "3x3", PNG)
|
|
56
|
+
assert result.tiles == [2, 4, 5]
|
|
57
|
+
assert result.positions == ["row 1, column 2", "row 2, column 1", "row 2, column 2"]
|
|
58
|
+
method, path, _, body, auth = fake.requests[0]
|
|
59
|
+
assert (method, path) == ("POST", "/v1/recognition/recaptcha")
|
|
60
|
+
assert body == {"task": "Select all images with buses", "grid": "3x3", "image_data": [PNG]}
|
|
61
|
+
assert "key" not in body and auth == f"Bearer {KEY}"
|
|
62
|
+
assert fake.polls == 2
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_file_path_and_data_url_and_bytes(tmp_path):
|
|
66
|
+
path = tmp_path / "grid.png"
|
|
67
|
+
path.write_bytes(base64.b64decode(PNG))
|
|
68
|
+
for image in (str(path), path, base64.b64decode(PNG), "data:image/png;base64," + PNG):
|
|
69
|
+
fake = FakeAPI(polls_before=0)
|
|
70
|
+
client(fake).solve_recaptcha("t", "3x3", image)
|
|
71
|
+
assert fake.requests[0][3]["image_data"] == [PNG]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_grid_validation_happens_before_any_request():
|
|
75
|
+
fake = FakeAPI()
|
|
76
|
+
with pytest.raises(ValueError):
|
|
77
|
+
client(fake).solve_recaptcha("t", "5x5", PNG)
|
|
78
|
+
with pytest.raises(ValueError):
|
|
79
|
+
client(fake).solve_recaptcha("t", "3x3", [PNG, PNG])
|
|
80
|
+
assert fake.requests == []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_other_types():
|
|
84
|
+
c = client(FakeAPI(polls_before=0, answer=["x7k2p"]))
|
|
85
|
+
assert c.solve_text(PNG).text == "x7k2p"
|
|
86
|
+
assert client(FakeAPI(polls_before=0, answer={"x": 42.5})).solve_geetest([PNG, PNG]).offset_percent == 42.5
|
|
87
|
+
fake = FakeAPI(polls_before=0, answer=[True, False, False, False, False, False])
|
|
88
|
+
assert client(fake).solve_funcaptcha("Pick the sheep", PNG, match=True).tiles == [1]
|
|
89
|
+
assert fake.requests[0][1] == "/v1/recognition/funcaptcha_match"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_errors_map_to_exceptions():
|
|
93
|
+
with pytest.raises(OutOfCreditError):
|
|
94
|
+
client(FakeAPI(create_error=16)).solve_recaptcha("t", "3x3", PNG)
|
|
95
|
+
bad = Solvecha("slv_wrong", http=httpx.Client(transport=httpx.MockTransport(FakeAPI())), poll_interval=0)
|
|
96
|
+
with pytest.raises(InvalidKeyError):
|
|
97
|
+
bad.status()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_missing_key_raises(monkeypatch):
|
|
101
|
+
monkeypatch.delenv("SOLVECHA_API_KEY", raising=False)
|
|
102
|
+
with pytest.raises(InvalidKeyError):
|
|
103
|
+
Solvecha()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_timeout_reports_the_job_id():
|
|
107
|
+
with pytest.raises(SolveTimeoutError) as info:
|
|
108
|
+
client(FakeAPI(polls_before=10**6)).solve("recaptcha", {}, timeout=0.05)
|
|
109
|
+
assert info.value.job_id == "job-1"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_report_and_status():
|
|
113
|
+
fake = FakeAPI()
|
|
114
|
+
c = client(fake)
|
|
115
|
+
assert c.status()["plan"] == "Free"
|
|
116
|
+
assert c.report_result("job-1", "accepted", "3x3")
|
|
117
|
+
assert fake.requests[-1][3] == {"job_id": "job-1", "verdict": "accepted", "grid": "3x3"}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def test_async_client():
|
|
121
|
+
fake = FakeAPI()
|
|
122
|
+
c = AsyncSolvecha(KEY, http=httpx.AsyncClient(transport=httpx.MockTransport(fake)), poll_interval=0)
|
|
123
|
+
assert (await c.solve_recaptcha("t", "3x3", PNG)).tiles == [2, 4, 5]
|
|
124
|
+
assert (await c.status())["credit"] == 99
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_tool_functions_never_raise():
|
|
128
|
+
funcs = functions(client(FakeAPI(create_error=16)))
|
|
129
|
+
out = funcs["solve_recaptcha_grid"]("t", "3x3", PNG)
|
|
130
|
+
assert "Out of credit" in out["error"]
|
|
131
|
+
assert "grid must be" in funcs["solve_recaptcha_grid"]("t", "9x9", PNG)["error"]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_openai_tools_dispatch():
|
|
135
|
+
tools, handle = openai_tools(client(FakeAPI()))
|
|
136
|
+
assert {t["function"]["name"] for t in tools} == {"solve_recaptcha_grid", "solve_text_captcha", "get_account_status", "report_result"}
|
|
137
|
+
out = json.loads(handle("solve_recaptcha_grid", json.dumps({"task": "t", "grid": "3x3", "image": PNG})))
|
|
138
|
+
assert out["selected_tiles"] == [2, 4, 5]
|
|
139
|
+
assert "unknown tool" in json.loads(handle("nope", "{}"))["error"]
|
|
140
|
+
assert "bad arguments" in json.loads(handle("solve_text_captcha", "{}"))["error"]
|
|
141
|
+
assert "not valid JSON" in json.loads(handle("get_account_status", "{oops"))["error"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Build the tools with each real framework. Skipped when the framework is not installed."""
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from solvecha import AsyncSolvecha, Solvecha
|
|
9
|
+
from solvecha.tools import (
|
|
10
|
+
browser_use_tools,
|
|
11
|
+
crewai_tools,
|
|
12
|
+
langchain_tools,
|
|
13
|
+
llamaindex_tools,
|
|
14
|
+
openai_agents_tools,
|
|
15
|
+
)
|
|
16
|
+
from test_client import KEY, PNG, FakeAPI
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make():
|
|
20
|
+
return Solvecha(KEY, http=httpx.Client(transport=httpx.MockTransport(FakeAPI())), poll_interval=0)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_langchain():
|
|
24
|
+
pytest.importorskip("langchain_core")
|
|
25
|
+
tools = {t.name: t for t in langchain_tools(make())}
|
|
26
|
+
assert set(tools) == {"solve_recaptcha_grid", "solve_text_captcha", "get_account_status", "report_result"}
|
|
27
|
+
out = tools["solve_recaptcha_grid"].invoke({"task": "t", "grid": "3x3", "image": PNG})
|
|
28
|
+
assert out["selected_tiles"] == [2, 4, 5]
|
|
29
|
+
assert tools["solve_recaptcha_grid"].args["grid"]["type"] == "string"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_llamaindex():
|
|
33
|
+
pytest.importorskip("llama_index.core")
|
|
34
|
+
tools = {t.metadata.name: t for t in llamaindex_tools(make())}
|
|
35
|
+
out = tools["solve_recaptcha_grid"](task="t", grid="3x3", image=PNG)
|
|
36
|
+
assert "[2, 4, 5]" in str(out)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_crewai():
|
|
40
|
+
pytest.importorskip("crewai")
|
|
41
|
+
tools = {t.name: t for t in crewai_tools(make())}
|
|
42
|
+
assert any("solve_recaptcha_grid" in n.replace(" ", "_").lower() for n in tools)
|
|
43
|
+
tool = next(t for n, t in tools.items() if "recaptcha" in n.lower())
|
|
44
|
+
assert "[2, 4, 5]" in str(tool.run(task="t", grid="3x3", image=PNG))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_openai_agents():
|
|
48
|
+
pytest.importorskip("agents")
|
|
49
|
+
tools = openai_agents_tools(make())
|
|
50
|
+
names = {t.name for t in tools}
|
|
51
|
+
assert "solve_recaptcha_grid" in names
|
|
52
|
+
schema = next(t for t in tools if t.name == "solve_recaptcha_grid").params_json_schema
|
|
53
|
+
assert set(schema["required"]) == {"task", "grid", "image"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def test_browser_use_action_is_registered():
|
|
57
|
+
pytest.importorskip("browser_use")
|
|
58
|
+
client = AsyncSolvecha(KEY, http=httpx.AsyncClient(transport=httpx.MockTransport(FakeAPI())), poll_interval=0)
|
|
59
|
+
tools = browser_use_tools(client)
|
|
60
|
+
assert "solve_recaptcha_in_tab" in tools.registry.registry.actions
|