debatr 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.
debatr/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Debatr CLI: run AI debates from the terminal and serve them to AI agents over MCP."""
2
+
3
+ __version__ = "0.1.0"
debatr/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from debatr.cli import main
2
+
3
+ main()
debatr/api.py ADDED
@@ -0,0 +1,310 @@
1
+ """Async client for the Debatr REST API (`{site}/api`), shared by the CLI and the MCP server.
2
+
3
+ Responses are plain JSON dicts as documented at `{site}/api/docs`. Every failure (HTTP error,
4
+ network problem, unexpected reply) is raised as `ApiError` with a message fit for a human or
5
+ an agent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import platform
12
+ from collections.abc import AsyncIterator, Callable
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ from debatr import __version__
20
+
21
+ USER_AGENT = f"debatr-cli/{__version__} (Python {platform.python_version()}; {platform.system()})"
22
+ TERMINAL_EVENTS = frozenset({"debate_completed", "debate_error", "debate_stopped"})
23
+ TERMINAL_STATUSES = frozenset({"completed", "error", "stopped"})
24
+
25
+
26
+ class ApiError(Exception):
27
+ def __init__(self, message: str, status: int | None = None) -> None:
28
+ super().__init__(message)
29
+ self.message = message
30
+ self.status = status
31
+
32
+
33
+ def _detail(response: httpx.Response) -> str | None:
34
+ try:
35
+ body = response.json()
36
+ except ValueError:
37
+ return None
38
+ detail = body.get("detail") if isinstance(body, dict) else None
39
+ if isinstance(detail, str):
40
+ return detail
41
+ if isinstance(detail, list) and detail and isinstance(detail[0], dict):
42
+ first = detail[0]
43
+ msg = str(first.get("msg") or "Invalid request").removeprefix("Value error, ")
44
+ loc = [str(p) for p in first.get("loc") or [] if p not in ("body", "query", "path")]
45
+ return f"{'.'.join(loc)}: {msg}" if loc else msg
46
+ return None
47
+
48
+
49
+ def error_message(response: httpx.Response, signed_in: bool) -> str:
50
+ status = response.status_code
51
+ detail = _detail(response)
52
+ if status == 401:
53
+ if signed_in:
54
+ return f"{detail or 'The API token was rejected'}. Run `debatr login` to sign in again."
55
+ return "Not signed in: run `debatr login` (or set DEBATR_TOKEN)."
56
+ if detail:
57
+ return detail
58
+ if status == 404:
59
+ return "Not found"
60
+ if status == 429:
61
+ return "Too many requests, try again later"
62
+ if status >= 500:
63
+ return f"The server had a problem ({status}), try again later"
64
+ return f"Request failed ({status})"
65
+
66
+
67
+ @dataclass
68
+ class SseEvent:
69
+ event: str
70
+ data: dict[str, Any]
71
+
72
+
73
+ async def parse_sse(lines: AsyncIterator[str]) -> AsyncIterator[tuple[str, str]]:
74
+ """(event, data) pairs from the lines of a text/event-stream (comments are pings)."""
75
+ event = "message"
76
+ data: list[str] = []
77
+ async for line in lines:
78
+ if line == "":
79
+ if data:
80
+ yield event, "\n".join(data)
81
+ event, data = "message", []
82
+ continue
83
+ if line.startswith(":"):
84
+ continue
85
+ field, _, value = line.partition(":")
86
+ value = value.removeprefix(" ")
87
+ if field == "event":
88
+ event = value or "message"
89
+ elif field == "data":
90
+ data.append(value)
91
+ if data:
92
+ yield event, "\n".join(data)
93
+
94
+
95
+ def debate_url(base_url: str, debate: dict[str, Any]) -> str:
96
+ """Share link when public, else the owner's page."""
97
+ return debate.get("share_url") or f"{base_url.rstrip('/')}/debate/{debate['id']}"
98
+
99
+
100
+ class DebatrClient:
101
+ def __init__(
102
+ self,
103
+ base_url: str,
104
+ token: str | None = None,
105
+ *,
106
+ transport: httpx.AsyncBaseTransport | None = None,
107
+ timeout: float = 30.0,
108
+ ) -> None:
109
+ self.base_url = base_url.rstrip("/")
110
+ self.token = token
111
+ headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
112
+ if token:
113
+ headers["Authorization"] = f"Bearer {token}"
114
+ self._http = httpx.AsyncClient(
115
+ base_url=f"{self.base_url}/api",
116
+ headers=headers,
117
+ timeout=httpx.Timeout(timeout, connect=15.0),
118
+ transport=transport,
119
+ follow_redirects=True,
120
+ )
121
+
122
+ async def __aenter__(self) -> DebatrClient:
123
+ return self
124
+
125
+ async def __aexit__(self, *_exc: object) -> None:
126
+ await self.aclose()
127
+
128
+ async def aclose(self) -> None:
129
+ await self._http.aclose()
130
+
131
+ @property
132
+ def signed_in(self) -> bool:
133
+ return bool(self.token)
134
+
135
+ # --- plumbing ------------------------------------------------------------------
136
+
137
+ async def request(
138
+ self,
139
+ method: str,
140
+ path: str,
141
+ *,
142
+ json_body: Any = None,
143
+ params: dict[str, Any] | None = None,
144
+ ) -> Any:
145
+ clean = {k: v for k, v in (params or {}).items() if v is not None and v != ""}
146
+ try:
147
+ response = await self._http.request(method, path, json=json_body, params=clean)
148
+ except httpx.TimeoutException as e:
149
+ raise ApiError(f"{self.base_url} did not answer in time") from e
150
+ except httpx.HTTPError as e:
151
+ raise ApiError(f"Could not reach {self.base_url}: {e}") from e
152
+ if response.status_code >= 400:
153
+ raise ApiError(error_message(response, self.signed_in), response.status_code)
154
+ if response.status_code == 204 or not response.content:
155
+ return None
156
+ try:
157
+ return response.json()
158
+ except ValueError as e:
159
+ raise ApiError(f"{self.base_url} did not answer like a Debatr server") from e
160
+
161
+ def web_url(self, path: str) -> str:
162
+ return f"{self.base_url}{path}"
163
+
164
+ def debate_url(self, debate: dict[str, Any]) -> str:
165
+ return debate_url(self.base_url, debate)
166
+
167
+ def absolute(self, url: str) -> str:
168
+ return url if url.startswith(("http://", "https://")) else f"{self.base_url}{url}"
169
+
170
+ # --- account -------------------------------------------------------------------
171
+
172
+ async def me(self) -> dict[str, Any] | None:
173
+ return await self.request("GET", "/auth/me")
174
+
175
+ async def current_token(self) -> dict[str, Any]:
176
+ return await self.request("GET", "/auth/tokens/current")
177
+
178
+ async def revoke_current_token(self) -> None:
179
+ await self.request("DELETE", "/auth/tokens/current")
180
+
181
+ async def device_start(self, client_name: str) -> dict[str, Any]:
182
+ return await self.request("POST", "/auth/device", json_body={"client_name": client_name})
183
+
184
+ async def device_poll(self, device_code: str) -> dict[str, Any]:
185
+ return await self.request(
186
+ "POST", "/auth/device/token", json_body={"device_code": device_code}
187
+ )
188
+
189
+ # --- catalogue -----------------------------------------------------------------
190
+
191
+ async def models(self) -> list[dict[str, Any]]:
192
+ body = await self.request("GET", "/models")
193
+ return list((body or {}).get("data") or [])
194
+
195
+ async def leaderboard(self) -> dict[str, Any]:
196
+ return await self.request("GET", "/gallery/leaderboard")
197
+
198
+ async def gallery(
199
+ self,
200
+ *,
201
+ q: str | None = None,
202
+ model: str | None = None,
203
+ sort: str = "trending",
204
+ period: str | None = None,
205
+ category: str | None = None,
206
+ limit: int = 20,
207
+ offset: int = 0,
208
+ ) -> dict[str, Any]:
209
+ params = {
210
+ "q": q,
211
+ "model": model,
212
+ "sort": sort,
213
+ "period": period,
214
+ "category": category,
215
+ "limit": limit,
216
+ "offset": offset,
217
+ }
218
+ return await self.request("GET", "/gallery", params=params)
219
+
220
+ async def public_debate(self, slug: str) -> dict[str, Any]:
221
+ return await self.request("GET", f"/gallery/{slug}")
222
+
223
+ # --- debates -------------------------------------------------------------------
224
+
225
+ async def estimate(self, config: dict[str, Any]) -> dict[str, Any]:
226
+ return await self.request("POST", "/debates/estimate", json_body=config)
227
+
228
+ async def create_debate(self, config: dict[str, Any]) -> dict[str, Any]:
229
+ return await self.request("POST", "/debates", json_body=config)
230
+
231
+ async def list_debates(
232
+ self, *, status: str | None = None, q: str | None = None, limit: int = 20, offset: int = 0
233
+ ) -> list[dict[str, Any]]:
234
+ params = {"status": status, "q": q, "limit": limit, "offset": offset}
235
+ return list(await self.request("GET", "/debates", params=params) or [])
236
+
237
+ async def debate(self, debate_id: str) -> dict[str, Any]:
238
+ return await self.request("GET", f"/debates/{debate_id}")
239
+
240
+ async def stop_debate(self, debate_id: str) -> dict[str, Any]:
241
+ return await self.request("POST", f"/debates/{debate_id}/stop")
242
+
243
+ async def delete_debate(self, debate_id: str) -> None:
244
+ await self.request("DELETE", f"/debates/{debate_id}")
245
+
246
+ async def publish(self, debate_id: str, category: str | None = None) -> dict[str, Any]:
247
+ return await self.request(
248
+ "POST", f"/debates/{debate_id}/publish", json_body={"category": category}
249
+ )
250
+
251
+ async def unpublish(self, debate_id: str) -> dict[str, Any]:
252
+ return await self.request("DELETE", f"/debates/{debate_id}/publish")
253
+
254
+ async def media(self, debate_id: str) -> dict[str, Any]:
255
+ return await self.request("GET", f"/debates/{debate_id}/media")
256
+
257
+ async def build_media(self, debate_id: str, options: dict[str, Any]) -> dict[str, Any]:
258
+ return await self.request("POST", f"/debates/{debate_id}/media", json_body=options)
259
+
260
+ async def events(self, debate_id: str) -> AsyncIterator[SseEvent]:
261
+ """Live events of a debate until it ends (the server closes the stream then)."""
262
+ try:
263
+ async with self._http.stream(
264
+ "GET",
265
+ f"/debates/{debate_id}/stream",
266
+ headers={"Accept": "text/event-stream", "Cache-Control": "no-cache"},
267
+ # The server pings every 15 s; a minute of silence means the connection is gone.
268
+ timeout=httpx.Timeout(30.0, read=60.0),
269
+ ) as response:
270
+ if response.status_code >= 400:
271
+ await response.aread()
272
+ raise ApiError(error_message(response, self.signed_in), response.status_code)
273
+ async for name, raw in parse_sse(response.aiter_lines()):
274
+ try:
275
+ data = json.loads(raw) if raw else {}
276
+ except ValueError:
277
+ continue
278
+ yield SseEvent(name, data if isinstance(data, dict) else {})
279
+ except httpx.TimeoutException as e:
280
+ raise ApiError("The live stream went silent") from e
281
+ except httpx.HTTPError as e:
282
+ raise ApiError(f"Lost the live stream: {e}") from e
283
+
284
+ async def download(
285
+ self,
286
+ url: str,
287
+ dest: Path,
288
+ progress: Callable[[int, int | None], None] | None = None,
289
+ ) -> Path:
290
+ """Stream a file (e.g. the debate's MP3) to `dest`; a partial file is removed."""
291
+ tmp = dest.with_name(f".{dest.name}.part")
292
+ try:
293
+ async with self._http.stream("GET", self.absolute(url), timeout=60.0) as response:
294
+ if response.status_code >= 400:
295
+ await response.aread()
296
+ raise ApiError(error_message(response, self.signed_in), response.status_code)
297
+ total = int(response.headers.get("content-length") or 0) or None
298
+ done = 0
299
+ with tmp.open("wb") as fh:
300
+ async for chunk in response.aiter_bytes():
301
+ fh.write(chunk)
302
+ done += len(chunk)
303
+ if progress:
304
+ progress(done, total)
305
+ tmp.replace(dest)
306
+ except httpx.HTTPError as e:
307
+ raise ApiError(f"Download failed: {e}") from e
308
+ finally:
309
+ tmp.unlink(missing_ok=True)
310
+ return dest