solvecha 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
solvecha/__init__.py ADDED
@@ -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
solvecha/client.py ADDED
@@ -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()
solvecha/core.py ADDED
@@ -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
solvecha/errors.py ADDED
@@ -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)
solvecha/results.py ADDED
@@ -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
solvecha/tools.py ADDED
@@ -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,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
+ ```
@@ -0,0 +1,10 @@
1
+ solvecha/__init__.py,sha256=X-hzAtoophvarrOOJ2c90dyY2O612-sv_cxI5Ztfq3U,590
2
+ solvecha/browser_use_action.py,sha256=YgaIaksOBUEAqoU3Sg4Al0q44MPp3TZHZ_aULwoh110,2181
3
+ solvecha/client.py,sha256=T2IDlfIdXboK77HkLlzm4VTH9kWA4wZcl9TmnvERsxY,8102
4
+ solvecha/core.py,sha256=tv3OAUYmLSmt924zd-JZHbltnS1IoEiebc85VD-rHMU,2352
5
+ solvecha/errors.py,sha256=vwiI2xnq5K-My1s84xQi08ste0q1stPgwkVt_Yxzzj0,1599
6
+ solvecha/results.py,sha256=LXt_HtUA48879rw1F91L8aja4a-V8sL3sh9dcGjOH-o,944
7
+ solvecha/tools.py,sha256=AZjUrR5GDZcqpJowUaPeHPz8DLslymt4y3apGgm_jRI,7201
8
+ solvecha-0.1.0.dist-info/METADATA,sha256=WG97P61rKkwDoVTdp4GSQCVVQv3aDrm2rFYsvyQq70U,3385
9
+ solvecha-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
10
+ solvecha-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any