langchain-ceki 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,30 @@
1
+ # Node
2
+ node_modules/
3
+ dist/
4
+ *.tgz
5
+ .npm
6
+ .npmrc
7
+ npm-debug.log*
8
+
9
+ # Python
10
+ __pycache__/
11
+ *.py[cod]
12
+ *$py.class
13
+ *.egg-info/
14
+ build/
15
+ .eggs/
16
+ .pytest_cache/
17
+ .coverage
18
+ .tox/
19
+
20
+ # Env / local
21
+ .env
22
+ .env.*
23
+ .venv/
24
+ venv/
25
+
26
+ # IDE
27
+ .idea/
28
+ .vscode/
29
+ *.swp
30
+ .DS_Store
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: langchain-ceki
3
+ Version: 0.1.0
4
+ Summary: LangChain tool for Ceki — real Chrome sessions for AI agents
5
+ Project-URL: homepage, https://ceki.me
6
+ Project-URL: repository, https://github.com/Ceki-me/langchain
7
+ Project-URL: issues, https://github.com/Ceki-me/langchain/issues
8
+ Author: iWedmak
9
+ License: MIT
10
+ Keywords: ai-agent,automation,browser,ceki,chrome,langchain,tool
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: httpx>=0.27.0
19
+ Requires-Dist: langchain-core>=0.3.0
20
+ Requires-Dist: pydantic>=2.0.0
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'test'
23
+ Requires-Dist: pytest>=8.0.0; extra == 'test'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # langchain-ceki
27
+
28
+ LangChain toolkit for [Ceki](https://ceki.me) — drive a real Chrome session from your LangChain agent. Structural tools that wrap [`ceki-sdk`](https://pypi.org/project/ceki-sdk/).
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install langchain-ceki
34
+ ```
35
+
36
+ ## Use
37
+
38
+ ```python
39
+ import os
40
+ os.environ["CEKI_API_KEY"] = "your_key_here" # or export it
41
+
42
+ from langchain_ceki import CekiToolkit
43
+ from langchain.agents import AgentExecutor, create_tool_calling_agent
44
+
45
+ toolkit = CekiToolkit(default_rent={"schedule_id": 4242, "mode": "main"})
46
+ tools = toolkit.get_tools()
47
+
48
+ agent = create_tool_calling_agent(llm, tools, prompt)
49
+ executor = AgentExecutor(agent=agent, tools=tools)
50
+
51
+ try:
52
+ result = await executor.ainvoke({
53
+ "input": (
54
+ "Open https://my-app.example.com, log in with the saved profile, "
55
+ "and return the dashboard's headline number."
56
+ ),
57
+ })
58
+ print(result["output"])
59
+ finally:
60
+ await toolkit.aclose() # ALWAYS — leaving sessions open burns credit
61
+ ```
62
+
63
+ ## Tools
64
+
65
+ | Tool | What it does |
66
+ |---|---|
67
+ | `ceki_rent_browser` | Rent a real Chrome session and return its `session_id`. Pass it to every other tool. |
68
+ | `ceki_navigate` | Open a URL. |
69
+ | `ceki_click` | Click at viewport coordinates. Mouse jitter ON by default; `human=False` to teleport. |
70
+ | `ceki_type` | Type text into the focused element. Cadence + jitter ON by default. |
71
+ | `ceki_scroll` | Scroll by `delta_y` pixels with easing. |
72
+ | `ceki_screenshot` | PNG of the current viewport as base64. |
73
+ | `ceki_snapshot` | Screenshot + drained chat messages from the provider. |
74
+ | `ceki_chat_send` | Send a chat message to the human provider (e.g. ask for a captcha code). |
75
+ | `ceki_stop` | End the session. Always call when done. |
76
+
77
+ Both sync (`tool._run` / `tool.invoke`) and async (`tool._arun` / `tool.ainvoke`) paths are supported. The sync path is safe to call from a synchronous LangChain runnable; calling it from inside an already-running event loop raises with a clear hint to switch to `ainvoke`.
78
+
79
+ Get an API key at [ceki.me](https://ceki.me).
80
+
81
+ ## Use responsibly
82
+
83
+ Use only on sites you own or have authorization to operate on.
84
+
85
+ ## License
86
+
87
+ MIT.
@@ -0,0 +1,62 @@
1
+ # langchain-ceki
2
+
3
+ LangChain toolkit for [Ceki](https://ceki.me) — drive a real Chrome session from your LangChain agent. Structural tools that wrap [`ceki-sdk`](https://pypi.org/project/ceki-sdk/).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install langchain-ceki
9
+ ```
10
+
11
+ ## Use
12
+
13
+ ```python
14
+ import os
15
+ os.environ["CEKI_API_KEY"] = "your_key_here" # or export it
16
+
17
+ from langchain_ceki import CekiToolkit
18
+ from langchain.agents import AgentExecutor, create_tool_calling_agent
19
+
20
+ toolkit = CekiToolkit(default_rent={"schedule_id": 4242, "mode": "main"})
21
+ tools = toolkit.get_tools()
22
+
23
+ agent = create_tool_calling_agent(llm, tools, prompt)
24
+ executor = AgentExecutor(agent=agent, tools=tools)
25
+
26
+ try:
27
+ result = await executor.ainvoke({
28
+ "input": (
29
+ "Open https://my-app.example.com, log in with the saved profile, "
30
+ "and return the dashboard's headline number."
31
+ ),
32
+ })
33
+ print(result["output"])
34
+ finally:
35
+ await toolkit.aclose() # ALWAYS — leaving sessions open burns credit
36
+ ```
37
+
38
+ ## Tools
39
+
40
+ | Tool | What it does |
41
+ |---|---|
42
+ | `ceki_rent_browser` | Rent a real Chrome session and return its `session_id`. Pass it to every other tool. |
43
+ | `ceki_navigate` | Open a URL. |
44
+ | `ceki_click` | Click at viewport coordinates. Mouse jitter ON by default; `human=False` to teleport. |
45
+ | `ceki_type` | Type text into the focused element. Cadence + jitter ON by default. |
46
+ | `ceki_scroll` | Scroll by `delta_y` pixels with easing. |
47
+ | `ceki_screenshot` | PNG of the current viewport as base64. |
48
+ | `ceki_snapshot` | Screenshot + drained chat messages from the provider. |
49
+ | `ceki_chat_send` | Send a chat message to the human provider (e.g. ask for a captcha code). |
50
+ | `ceki_stop` | End the session. Always call when done. |
51
+
52
+ Both sync (`tool._run` / `tool.invoke`) and async (`tool._arun` / `tool.ainvoke`) paths are supported. The sync path is safe to call from a synchronous LangChain runnable; calling it from inside an already-running event loop raises with a clear hint to switch to `ainvoke`.
53
+
54
+ Get an API key at [ceki.me](https://ceki.me).
55
+
56
+ ## Use responsibly
57
+
58
+ Use only on sites you own or have authorization to operate on.
59
+
60
+ ## License
61
+
62
+ MIT.
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "langchain-ceki"
7
+ version = "0.1.0"
8
+ description = "LangChain tool for Ceki — real Chrome sessions for AI agents"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [
12
+ { name = "iWedmak" },
13
+ ]
14
+ requires-python = ">=3.10"
15
+ keywords = ["langchain", "tool", "browser", "ai-agent", "automation", "ceki", "chrome"]
16
+ classifiers = [
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Software Development :: Libraries",
23
+ ]
24
+ dependencies = [
25
+ "langchain-core>=0.3.0",
26
+ "httpx>=0.27.0",
27
+ "pydantic>=2.0.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ homepage = "https://ceki.me"
32
+ repository = "https://github.com/Ceki-me/langchain"
33
+ issues = "https://github.com/Ceki-me/langchain/issues"
34
+
35
+ [project.optional-dependencies]
36
+ test = [
37
+ "pytest>=8.0.0",
38
+ "pytest-asyncio>=0.23.0",
39
+ ]
40
+
41
+ [tool.hatch.build]
42
+ include = [
43
+ "src/langchain_ceki/**/*.py",
44
+ ]
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/langchain_ceki"]
@@ -0,0 +1,8 @@
1
+ """LangChain toolkit for Ceki — drive a real Chrome session from your agent.
2
+
3
+ Use only on sites you own or have authorization to operate on.
4
+ """
5
+ from langchain_ceki.toolkit import CekiToolkit, get_ceki_tools
6
+
7
+ __all__ = ["CekiToolkit", "get_ceki_tools"]
8
+ __version__ = "0.1.0"
@@ -0,0 +1,451 @@
1
+ """LangChain toolkit for Ceki — structural tools backed by the async ceki-sdk.
2
+
3
+ Architecture (Variant C, fixed with the Ceki backend team):
4
+
5
+ This package is a THIN WRAPPER. The agent's own LLM decides which low-level
6
+ tool to call (rent_browser, navigate, click, type, ...) and in what order.
7
+ There is no server-side natural-language endpoint and no LLM lives here.
8
+
9
+ Every tool exposes both `_run` (sync) and `_arun` (async). `_run` reuses the
10
+ toolkit's own asyncio loop when one is already running; otherwise it spins
11
+ up a private loop. This means the toolkit is safe to use from a synchronous
12
+ LangChain runnable AND inside an async agent.
13
+
14
+ Use only on sites you own or have authorization to operate on.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import base64
20
+ import json
21
+ import os
22
+ import threading
23
+ from typing import Any, ClassVar, Optional
24
+
25
+ from ceki_sdk import Client
26
+ from ceki_sdk._browser import Browser
27
+ from langchain_core.tools import BaseTool
28
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
29
+
30
+
31
+ DEFAULT_API_URL = "https://api.ceki.me"
32
+ DEFAULT_RELAY_URL = "wss://relay.ceki.me"
33
+ DEFAULT_CHAT_URL = "https://chat.ceki.me"
34
+
35
+
36
+ # ──────────────────────────────────────────────────────────────────────────
37
+ # Async-bridge — lets sync `_run` reach the toolkit's coroutines without
38
+ # blocking an enclosing event loop. If the caller is sync (no running loop),
39
+ # we spin up a dedicated background loop in a daemon thread and reuse it.
40
+ # ──────────────────────────────────────────────────────────────────────────
41
+ class _AsyncBridge:
42
+ def __init__(self) -> None:
43
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
44
+ self._thread: Optional[threading.Thread] = None
45
+ self._lock = threading.Lock()
46
+
47
+ def _ensure(self) -> asyncio.AbstractEventLoop:
48
+ with self._lock:
49
+ if self._loop is None or not self._loop.is_running():
50
+ self._loop = asyncio.new_event_loop()
51
+ self._thread = threading.Thread(
52
+ target=self._loop.run_forever, daemon=True, name="ceki-toolkit-loop"
53
+ )
54
+ self._thread.start()
55
+ return self._loop
56
+
57
+ def run(self, coro: Any) -> Any:
58
+ # If we're already inside a running event loop, the caller is async
59
+ # and should have hit `_arun` directly. Falling through to sync is
60
+ # almost always a bug; raise so the agent author notices.
61
+ try:
62
+ asyncio.get_running_loop()
63
+ except RuntimeError:
64
+ pass
65
+ else:
66
+ raise RuntimeError(
67
+ "CekiToolkit sync tool was invoked from inside a running event loop. "
68
+ "Use the async LangChain runnable path (`ainvoke`) so the tool's "
69
+ "_arun() is called instead."
70
+ )
71
+ loop = self._ensure()
72
+ return asyncio.run_coroutine_threadsafe(coro, loop).result()
73
+
74
+ def close(self) -> None:
75
+ with self._lock:
76
+ loop = self._loop
77
+ self._loop = None
78
+ self._thread = None
79
+ if loop and loop.is_running():
80
+ loop.call_soon_threadsafe(loop.stop)
81
+
82
+
83
+ # ──────────────────────────────────────────────────────────────────────────
84
+ # Toolkit
85
+ # ──────────────────────────────────────────────────────────────────────────
86
+ class CekiToolkit:
87
+ """Container that owns the Ceki Client + active Browser sessions.
88
+
89
+ Build it once per agent run, call :meth:`get_tools` to get the structural
90
+ toolkit, and remember to ``await toolkit.aclose()`` (or ``toolkit.close()``)
91
+ in a ``finally`` block.
92
+
93
+ Example::
94
+
95
+ from langchain_ceki import CekiToolkit
96
+
97
+ toolkit = CekiToolkit(default_rent={"schedule_id": 4242})
98
+ tools = toolkit.get_tools()
99
+ # pass `tools` to any LangChain agent
100
+ # ...
101
+ await toolkit.aclose()
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ *,
107
+ api_key: Optional[str] = None,
108
+ api_url: Optional[str] = None,
109
+ relay_url: Optional[str] = None,
110
+ chat_url: Optional[str] = None,
111
+ basic_auth: Optional[tuple[str, str]] = None,
112
+ default_rent: Optional[dict[str, Any]] = None,
113
+ ) -> None:
114
+ key = api_key or os.environ.get("CEKI_API_KEY", "")
115
+ if not key:
116
+ raise ValueError(
117
+ "CEKI_API_KEY not set. Sign up at https://ceki.me and export the API key."
118
+ )
119
+ self._api_key = key
120
+ self._api_url = api_url or os.environ.get("CEKI_API_URL") or DEFAULT_API_URL
121
+ self._relay_url = relay_url or os.environ.get("CEKI_RELAY_URL") or DEFAULT_RELAY_URL
122
+ self._chat_url = chat_url or os.environ.get("CEKI_CHAT_URL") or DEFAULT_CHAT_URL
123
+ self._basic_auth = basic_auth
124
+ self._default_rent: dict[str, Any] = dict(default_rent or {})
125
+ self._client: Optional[Client] = None
126
+ self._sessions: dict[str, Browser] = {}
127
+ self._bridge = _AsyncBridge()
128
+ self._connect_lock = asyncio.Lock()
129
+
130
+ # ─── lifecycle ────────────────────────────────────────────────────
131
+ async def _aget_client(self) -> Client:
132
+ if self._client is not None:
133
+ return self._client
134
+ async with self._connect_lock:
135
+ if self._client is None:
136
+ client = Client(
137
+ api_key=self._api_key,
138
+ relay_url=self._relay_url,
139
+ api_url=self._api_url,
140
+ chat_url=self._chat_url,
141
+ basic_auth=self._basic_auth,
142
+ )
143
+ await client._connect() # type: ignore[attr-defined]
144
+ self._client = client
145
+ return self._client
146
+
147
+ async def aclose(self) -> None:
148
+ """Close every open session and disconnect from the relay."""
149
+ sessions = list(self._sessions.values())
150
+ self._sessions.clear()
151
+ for s in sessions:
152
+ try:
153
+ await s.close()
154
+ except Exception:
155
+ pass
156
+ if self._client is not None:
157
+ try:
158
+ await self._client.disconnect()
159
+ except Exception:
160
+ pass
161
+ self._client = None
162
+ self._bridge.close()
163
+
164
+ def close(self) -> None:
165
+ """Sync variant of :meth:`aclose` for non-async agents."""
166
+ self._bridge.run(self.aclose())
167
+
168
+ # ─── session bookkeeping ──────────────────────────────────────────
169
+ def _require_session(self, session_id: str) -> Browser:
170
+ b = self._sessions.get(session_id)
171
+ if b is None:
172
+ raise ValueError(
173
+ f"session_id={session_id!r} is not active. "
174
+ "Call ceki_rent_browser first or pass an id returned by it."
175
+ )
176
+ return b
177
+
178
+ # ─── public API ───────────────────────────────────────────────────
179
+ def get_tools(self) -> list[BaseTool]:
180
+ return [
181
+ CekiRentBrowserTool(toolkit=self),
182
+ CekiNavigateTool(toolkit=self),
183
+ CekiClickTool(toolkit=self),
184
+ CekiTypeTool(toolkit=self),
185
+ CekiScrollTool(toolkit=self),
186
+ CekiScreenshotTool(toolkit=self),
187
+ CekiSnapshotTool(toolkit=self),
188
+ CekiChatSendTool(toolkit=self),
189
+ CekiStopTool(toolkit=self),
190
+ ]
191
+
192
+
193
+ def get_ceki_tools(**kwargs: Any) -> list[BaseTool]:
194
+ """Convenience: build a toolkit and return its tools in one line.
195
+
196
+ The caller owns the toolkit and is responsible for closing it. To close,
197
+ grab it off any tool: ``tools[0].toolkit.close()``.
198
+ """
199
+ tk = CekiToolkit(**kwargs)
200
+ return tk.get_tools()
201
+
202
+
203
+ # ──────────────────────────────────────────────────────────────────────────
204
+ # Tool base
205
+ # ──────────────────────────────────────────────────────────────────────────
206
+ class _CekiToolBase(BaseTool):
207
+ """Shared plumbing: ``_run`` defers to ``_arun`` through the bridge."""
208
+
209
+ model_config = ConfigDict(arbitrary_types_allowed=True)
210
+
211
+ toolkit: CekiToolkit = Field(..., exclude=True)
212
+
213
+ def _run(self, *args: Any, **kwargs: Any) -> Any:
214
+ return self.toolkit._bridge.run(self._arun(*args, **kwargs))
215
+
216
+
217
+ # ──────────────────────────────────────────────────────────────────────────
218
+ # Tool input schemas
219
+ # ──────────────────────────────────────────────────────────────────────────
220
+ class _RentInput(BaseModel):
221
+ schedule_id: Optional[int] = Field(
222
+ default=None,
223
+ description="Specific schedule_id to rent. Omit to take the toolkit default.",
224
+ )
225
+ mode: Optional[str] = Field(
226
+ default=None,
227
+ description="Profile mode: 'main' or 'incognito'.",
228
+ )
229
+
230
+
231
+ class _SessionOnly(BaseModel):
232
+ session_id: str = Field(..., description="Session id returned by ceki_rent_browser.")
233
+
234
+
235
+ class _NavigateInput(_SessionOnly):
236
+ url: str = Field(..., description="Absolute http/https URL to open.")
237
+
238
+
239
+ class _ClickInput(_SessionOnly):
240
+ x: float
241
+ y: float
242
+ human: Optional[bool] = Field(
243
+ default=None,
244
+ description="Pass false to skip mouse-jitter humanization for this call.",
245
+ )
246
+
247
+
248
+ class _TypeInput(_SessionOnly):
249
+ text: str
250
+ human: Optional[bool] = None
251
+
252
+
253
+ class _ScrollInput(_SessionOnly):
254
+ delta_y: float = Field(..., description="Vertical scroll delta in CSS pixels (negative = up).")
255
+ x: Optional[int] = 0
256
+ y: Optional[int] = 0
257
+ human: Optional[bool] = None
258
+
259
+
260
+ class _ChatSendInput(_SessionOnly):
261
+ text: str = Field(..., description="Message to send to the human provider via chat.")
262
+
263
+
264
+ # ──────────────────────────────────────────────────────────────────────────
265
+ # Tools
266
+ # ──────────────────────────────────────────────────────────────────────────
267
+ class CekiRentBrowserTool(_CekiToolBase):
268
+ name: ClassVar[str] = "ceki_rent_browser"
269
+ description: ClassVar[str] = (
270
+ "Rent a real Chrome session from the Ceki marketplace. Returns a session_id "
271
+ "you must pass to every other ceki_* tool. Call this BEFORE navigate/click/type/etc."
272
+ )
273
+ args_schema: ClassVar[type[BaseModel]] = _RentInput
274
+
275
+ async def _arun(
276
+ self,
277
+ schedule_id: Optional[int] = None,
278
+ mode: Optional[str] = None,
279
+ **_: Any,
280
+ ) -> str:
281
+ client = await self.toolkit._aget_client()
282
+ sid = schedule_id or self.toolkit._default_rent.get("schedule_id")
283
+ if sid is None:
284
+ raise ValueError(
285
+ "ceki_rent_browser: schedule_id is required. Pass it explicitly or "
286
+ "configure CekiToolkit(default_rent={'schedule_id': ...})."
287
+ )
288
+ m = mode or self.toolkit._default_rent.get("mode") or "incognito"
289
+ if m not in ("incognito", "main"):
290
+ raise ValueError(f"mode must be 'main' or 'incognito', got {m!r}")
291
+ browser = await client.rent(sid, mode=m)
292
+ self.toolkit._sessions[browser.session_id] = browser
293
+ return json.dumps(
294
+ {"session_id": browser.session_id, "schedule_id": sid, "mode": m}
295
+ )
296
+
297
+
298
+ class CekiNavigateTool(_CekiToolBase):
299
+ name: ClassVar[str] = "ceki_navigate"
300
+ description: ClassVar[str] = (
301
+ "Open a URL in the rented Chrome session. Waits up to 30s for navigation."
302
+ )
303
+ args_schema: ClassVar[type[BaseModel]] = _NavigateInput
304
+
305
+ async def _arun(self, session_id: str, url: str, **_: Any) -> str:
306
+ b = self.toolkit._require_session(session_id)
307
+ res = await b.navigate(url)
308
+ return json.dumps({"ok": True, "url": res.get("url") if isinstance(res, dict) else url})
309
+
310
+
311
+ class CekiClickTool(_CekiToolBase):
312
+ name: ClassVar[str] = "ceki_click"
313
+ description: ClassVar[str] = (
314
+ "Click at viewport coordinates in the rented session. Mouse jitter is ON by default; "
315
+ "pass human=false to teleport."
316
+ )
317
+ args_schema: ClassVar[type[BaseModel]] = _ClickInput
318
+
319
+ async def _arun(
320
+ self, session_id: str, x: float, y: float, human: Optional[bool] = None, **_: Any
321
+ ) -> str:
322
+ b = self.toolkit._require_session(session_id)
323
+ await b.click(x, y, human=human)
324
+ return json.dumps({"ok": True})
325
+
326
+
327
+ class CekiTypeTool(_CekiToolBase):
328
+ name: ClassVar[str] = "ceki_type"
329
+ description: ClassVar[str] = (
330
+ "Type text into the currently-focused element of the rented session. "
331
+ "Click an input first; humanization (cadence + jitter) is ON by default."
332
+ )
333
+ args_schema: ClassVar[type[BaseModel]] = _TypeInput
334
+
335
+ async def _arun(
336
+ self, session_id: str, text: str, human: Optional[bool] = None, **_: Any
337
+ ) -> str:
338
+ b = self.toolkit._require_session(session_id)
339
+ await b.type(text, human=human)
340
+ return json.dumps({"ok": True})
341
+
342
+
343
+ class CekiScrollTool(_CekiToolBase):
344
+ name: ClassVar[str] = "ceki_scroll"
345
+ description: ClassVar[str] = (
346
+ "Scroll the rented session by delta_y CSS pixels. Easing is ON by default; "
347
+ "pass human=false for a raw CDP wheel."
348
+ )
349
+ args_schema: ClassVar[type[BaseModel]] = _ScrollInput
350
+
351
+ async def _arun(
352
+ self,
353
+ session_id: str,
354
+ delta_y: float,
355
+ x: Optional[int] = 0,
356
+ y: Optional[int] = 0,
357
+ human: Optional[bool] = None,
358
+ **_: Any,
359
+ ) -> str:
360
+ b = self.toolkit._require_session(session_id)
361
+ await b.scroll(x=int(x or 0), y=int(y or 0), delta_y=int(delta_y), human=human)
362
+ return json.dumps({"ok": True})
363
+
364
+
365
+ class CekiScreenshotTool(_CekiToolBase):
366
+ name: ClassVar[str] = "ceki_screenshot"
367
+ description: ClassVar[str] = (
368
+ "Take a PNG screenshot of the rented session's current viewport. Returns base64."
369
+ )
370
+ args_schema: ClassVar[type[BaseModel]] = _SessionOnly
371
+
372
+ async def _arun(self, session_id: str, **_: Any) -> str:
373
+ b = self.toolkit._require_session(session_id)
374
+ shot = await b.screenshot()
375
+ if isinstance(shot, bytes):
376
+ b64 = base64.b64encode(shot).decode("ascii")
377
+ elif isinstance(shot, dict) and "data" in shot:
378
+ b64 = shot["data"]
379
+ else:
380
+ raise RuntimeError(f"unexpected screenshot shape: {type(shot).__name__}")
381
+ return json.dumps(
382
+ {"ok": True, "mime": "image/png", "base64": b64, "bytes": (len(b64) * 3) // 4}
383
+ )
384
+
385
+
386
+ class CekiSnapshotTool(_CekiToolBase):
387
+ name: ClassVar[str] = "ceki_snapshot"
388
+ description: ClassVar[str] = (
389
+ "Take a screenshot AND drain pending chat messages from the provider. "
390
+ "Returns a JSON blob with both."
391
+ )
392
+ args_schema: ClassVar[type[BaseModel]] = _SessionOnly
393
+
394
+ async def _arun(self, session_id: str, **_: Any) -> str:
395
+ b = self.toolkit._require_session(session_id)
396
+ snap = await b.snapshot()
397
+ screenshot = getattr(snap, "screenshot", None)
398
+ if isinstance(screenshot, bytes):
399
+ screenshot = base64.b64encode(screenshot).decode("ascii")
400
+ return json.dumps(
401
+ {
402
+ "ok": True,
403
+ "screenshot_base64": screenshot,
404
+ "chat": getattr(snap, "chat", None) or [],
405
+ },
406
+ default=str,
407
+ )
408
+
409
+
410
+ class CekiChatSendTool(_CekiToolBase):
411
+ name: ClassVar[str] = "ceki_chat_send"
412
+ description: ClassVar[str] = (
413
+ "Send a chat message to the human provider of the rented session "
414
+ "(e.g. to ask for a captcha code or 2FA)."
415
+ )
416
+ args_schema: ClassVar[type[BaseModel]] = _ChatSendInput
417
+
418
+ async def _arun(self, session_id: str, text: str, **_: Any) -> str:
419
+ b = self.toolkit._require_session(session_id)
420
+ await b.chat.send(text)
421
+ return json.dumps({"ok": True})
422
+
423
+
424
+ class CekiStopTool(_CekiToolBase):
425
+ name: ClassVar[str] = "ceki_stop"
426
+ description: ClassVar[str] = (
427
+ "End the rented Chrome session. Always call this when you're done — leaving "
428
+ "sessions open burns the user's credit."
429
+ )
430
+ args_schema: ClassVar[type[BaseModel]] = _SessionOnly
431
+
432
+ async def _arun(self, session_id: str, **_: Any) -> str:
433
+ b = self.toolkit._require_session(session_id)
434
+ await b.close()
435
+ self.toolkit._sessions.pop(session_id, None)
436
+ return json.dumps({"ok": True})
437
+
438
+
439
+ __all__ = [
440
+ "CekiToolkit",
441
+ "get_ceki_tools",
442
+ "CekiRentBrowserTool",
443
+ "CekiNavigateTool",
444
+ "CekiClickTool",
445
+ "CekiTypeTool",
446
+ "CekiScrollTool",
447
+ "CekiScreenshotTool",
448
+ "CekiSnapshotTool",
449
+ "CekiChatSendTool",
450
+ "CekiStopTool",
451
+ ]