telegram-opencode-bridge-bot 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.
opencode/client.py ADDED
@@ -0,0 +1,443 @@
1
+ """Async HTTP client for the OpenCode serve API.
2
+
3
+ The OpenCode CLI exposes a REST API via ``opencode serve`` (default
4
+ ``http://localhost:4096``). This module wraps every documented endpoint
5
+ with an ergonomic, fully-async interface built on :pymod:`aiohttp`.
6
+
7
+ Endpoints covered
8
+ -----------------
9
+ * ``GET /session`` — list all sessions
10
+ * ``POST /session`` — create a new session
11
+ * ``POST /session/{id}/message`` — send a prompt / message
12
+ * ``POST /session/{id}/share`` — share a session (get public URL)
13
+
14
+ Authentication is HTTP Basic Auth (username + password).
15
+
16
+ Usage example::
17
+
18
+ async with OpenCodeClient(username="u", password="p") as client:
19
+ session = await client.create_session()
20
+ reply = await client.send_message(session["id"], "Hello!")
21
+ print(reply.content)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import logging
28
+ from dataclasses import dataclass, field
29
+ from typing import Any, Dict, List, Optional
30
+
31
+ import aiohttp
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Data models
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ @dataclass
41
+ class OpenCodeMessage:
42
+ """Structured representation of a response message from OpenCode.
43
+
44
+ Attributes:
45
+ role: The role of the message author (e.g. ``"assistant"``).
46
+ content: The textual content of the response.
47
+ session_id: The ID of the session this message belongs to.
48
+ tool_calls: Optional list of tool-call descriptors returned by the
49
+ model. Each entry is a free-form dict whose shape depends on
50
+ the underlying LLM provider.
51
+ """
52
+
53
+ role: str
54
+ content: str
55
+ session_id: str
56
+ tool_calls: List[Dict[str, Any]] = field(default_factory=list)
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Exceptions
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ class OpenCodeError(Exception):
65
+ """Base exception for OpenCode client errors."""
66
+
67
+
68
+ class OpenCodeConnectionError(OpenCodeError):
69
+ """Raised when the server cannot be reached after all retries."""
70
+
71
+
72
+ class OpenCodeAPIError(OpenCodeError):
73
+ """Raised when the API returns an unexpected error status."""
74
+
75
+ def __init__(self, status: int, message: str) -> None:
76
+ self.status = status
77
+ super().__init__(f"HTTP {status}: {message}")
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Client
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ class OpenCodeClient:
86
+ """Async HTTP client for the OpenCode serve API.
87
+
88
+ Parameters:
89
+ server_url: Base URL of the running ``opencode serve`` instance.
90
+ username: HTTP Basic Auth username (leave empty to skip auth).
91
+ password: HTTP Basic Auth password.
92
+ timeout: Total request timeout in **seconds** (default 300 – five
93
+ minutes, long enough for LLM responses).
94
+ max_retries: Number of attempts for retryable failures (5xx, network
95
+ errors, 429 rate limits).
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ server_url: str = "http://localhost:4096",
101
+ username: str = "",
102
+ password: str = "",
103
+ timeout: int = 300,
104
+ max_retries: int = 3,
105
+ ) -> None:
106
+ self.server_url: str = server_url.rstrip("/")
107
+ # Set total to None to disable the timeout in aiohttp if timeout is 0 or None
108
+ total_timeout = timeout if timeout and timeout > 0 else None
109
+ self.timeout: aiohttp.ClientTimeout = aiohttp.ClientTimeout(total=total_timeout)
110
+ self.max_retries: int = max_retries
111
+ self._session: Optional[aiohttp.ClientSession] = None
112
+
113
+ # Build Basic Auth header only when credentials are provided.
114
+ self._auth: Optional[aiohttp.BasicAuth] = None
115
+ if username and password:
116
+ self._auth = aiohttp.BasicAuth(username, password)
117
+
118
+ # -- async context-manager support --------------------------------------
119
+
120
+ async def __aenter__(self) -> "OpenCodeClient":
121
+ """Allow ``async with OpenCodeClient(...) as client:`` usage."""
122
+ await self._get_session()
123
+ return self
124
+
125
+ async def __aexit__(self, *exc: object) -> None:
126
+ await self.close()
127
+
128
+ # -- internal helpers ---------------------------------------------------
129
+
130
+ async def _get_session(self) -> aiohttp.ClientSession:
131
+ """Return the shared :class:`aiohttp.ClientSession`, creating it lazily."""
132
+ if self._session is None or self._session.closed:
133
+ self._session = aiohttp.ClientSession(
134
+ timeout=self.timeout,
135
+ auth=self._auth,
136
+ )
137
+ return self._session
138
+
139
+ async def close(self) -> None:
140
+ """Gracefully close the underlying HTTP session.
141
+
142
+ Safe to call multiple times.
143
+ """
144
+ if self._session and not self._session.closed:
145
+ await self._session.close()
146
+ logger.debug("HTTP session closed.")
147
+
148
+ async def _request(
149
+ self,
150
+ method: str,
151
+ endpoint: str,
152
+ *,
153
+ json_data: Optional[Dict[str, Any]] = None,
154
+ params: Optional[Dict[str, str]] = None,
155
+ ) -> Any:
156
+ """Execute an HTTP request with automatic retries.
157
+
158
+ Retries are performed for:
159
+ * HTTP 429 (rate-limited) — honours ``Retry-After`` header.
160
+ * HTTP 5xx (server errors) — exponential back-off.
161
+ * Network-level ``aiohttp.ClientError`` — exponential back-off.
162
+
163
+ Client errors (4xx other than 429) are raised immediately.
164
+
165
+ Returns:
166
+ Parsed JSON body (``dict`` or ``list``) when the response has a
167
+ JSON content-type, otherwise a ``dict`` with ``content`` (text)
168
+ and ``status`` keys.
169
+
170
+ Raises:
171
+ OpenCodeAPIError: For non-retryable HTTP errors.
172
+ OpenCodeConnectionError: After all retries are exhausted.
173
+ """
174
+ url = f"{self.server_url}/{endpoint.lstrip('/')}"
175
+ session = await self._get_session()
176
+
177
+ last_error: Optional[Exception] = None
178
+
179
+ for attempt in range(1, self.max_retries + 1):
180
+ try:
181
+ logger.debug("[Attempt %d/%d] %s %s", attempt, self.max_retries, method, url)
182
+
183
+ async with session.request(
184
+ method,
185
+ url,
186
+ json=json_data,
187
+ params=params,
188
+ ) as resp:
189
+ # --- rate limiting ------------------------------------
190
+ if resp.status == 429:
191
+ retry_after = int(resp.headers.get("Retry-After", "5"))
192
+ logger.warning(
193
+ "Rate-limited (429). Retrying in %ds (attempt %d/%d).",
194
+ retry_after,
195
+ attempt,
196
+ self.max_retries,
197
+ )
198
+ await asyncio.sleep(retry_after)
199
+ continue
200
+
201
+ # --- raise on error -----------------------------------
202
+ if resp.status >= 400:
203
+ body = await resp.text()
204
+ if resp.status < 500:
205
+ # Client error — do NOT retry.
206
+ raise OpenCodeAPIError(resp.status, body)
207
+ # Server error — let retry logic handle it.
208
+ raise aiohttp.ClientResponseError(
209
+ request_info=resp.request_info,
210
+ history=resp.history,
211
+ status=resp.status,
212
+ message=body,
213
+ )
214
+
215
+ # --- parse success body --------------------------------
216
+ content_type = resp.content_type or ""
217
+ if "json" in content_type:
218
+ return await resp.json()
219
+ text = await resp.text()
220
+ return {"content": text, "status": resp.status}
221
+
222
+ except OpenCodeAPIError:
223
+ raise # never retry 4xx
224
+
225
+ except aiohttp.ClientResponseError as exc:
226
+ last_error = exc
227
+ logger.error("Server error %d: %s", exc.status, exc.message)
228
+
229
+ except aiohttp.ClientError as exc:
230
+ last_error = exc
231
+ logger.error("Connection error: %s", exc)
232
+
233
+ # Exponential back-off before next attempt.
234
+ if attempt < self.max_retries:
235
+ wait = min(2 ** attempt, 30)
236
+ logger.info("Retrying in %ds …", wait)
237
+ await asyncio.sleep(wait)
238
+
239
+ raise OpenCodeConnectionError(
240
+ f"Failed after {self.max_retries} retries: {last_error}"
241
+ )
242
+
243
+ # -- public API ---------------------------------------------------------
244
+
245
+ async def is_available(self) -> bool:
246
+ """Check whether the OpenCode server is reachable.
247
+
248
+ Returns ``True`` if a request to the sessions endpoint succeeds
249
+ (status < 500), ``False`` otherwise. Does **not** raise.
250
+ """
251
+ try:
252
+ session = await self._get_session()
253
+ async with session.get(f"{self.server_url}/session") as resp:
254
+ reachable = resp.status < 500
255
+ logger.debug("Server availability check: %s (status %d)", reachable, resp.status)
256
+ return reachable
257
+ except Exception as exc: # noqa: BLE001
258
+ logger.debug("Server unreachable: %s", exc)
259
+ return False
260
+
261
+ async def list_sessions(self) -> List[Dict[str, Any]]:
262
+ """List all existing OpenCode sessions.
263
+
264
+ Returns:
265
+ A list of session dicts. The exact keys depend on the OpenCode
266
+ version, but ``id`` is always present.
267
+ """
268
+ result = await self._request("GET", "/session")
269
+ if isinstance(result, list):
270
+ return result
271
+ # Some API versions wrap the list in an object.
272
+ if isinstance(result, dict):
273
+ return result.get("sessions", result.get("data", []))
274
+ return []
275
+
276
+ async def create_session(self, directory: Optional[str] = None) -> Dict[str, Any]:
277
+ """Create a new OpenCode session.
278
+
279
+ Parameters:
280
+ directory: Optional workspace/working directory for the session.
281
+
282
+ Returns:
283
+ A dict describing the newly created session. The ``id`` key
284
+ contains the session identifier needed for subsequent calls.
285
+ """
286
+ params = {}
287
+ if directory:
288
+ params["directory"] = directory
289
+
290
+ result = await self._request("POST", "/session", params=params)
291
+ logger.info("Created new session: %s", result.get("id", "<unknown>") if isinstance(result, dict) else result)
292
+ return result
293
+
294
+ async def send_message(
295
+ self,
296
+ session_id: str,
297
+ content: str,
298
+ model: Optional[str] = None,
299
+ ) -> Optional[OpenCodeMessage]:
300
+ """Send a prompt to an OpenCode session and return the response.
301
+
302
+ Parameters:
303
+ session_id: The target session identifier.
304
+ content: The user-facing prompt text.
305
+ model: Optional model identifier (e.g. "provider/model").
306
+
307
+ Returns:
308
+ An :class:`OpenCodeMessage` containing the assistant's reply.
309
+ """
310
+ payload = {
311
+ "parts": [
312
+ {
313
+ "type": "text",
314
+ "text": content,
315
+ }
316
+ ]
317
+ }
318
+
319
+ if model:
320
+ if "/" in model:
321
+ provider_id, model_id = model.split("/", 1)
322
+ payload["model"] = {
323
+ "providerID": provider_id.strip(),
324
+ "modelID": model_id.strip(),
325
+ }
326
+ else:
327
+ payload["model"] = {
328
+ "modelID": model.strip(),
329
+ }
330
+
331
+ result = await self._request(
332
+ "POST",
333
+ f"/session/{session_id}/message",
334
+ json_data=payload,
335
+ )
336
+
337
+ logger.info("Raw OpenCode API response: %s", result)
338
+
339
+ if result is None:
340
+ return None
341
+
342
+ if isinstance(result, dict):
343
+ # Check for abort/cancel/interrupt finish reasons
344
+ info = result.get("info", {})
345
+ finish_reason = ""
346
+ if isinstance(info, dict):
347
+ finish_reason = info.get("finish", "")
348
+ if not finish_reason:
349
+ finish_reason = result.get("finish", "")
350
+
351
+ if str(finish_reason).lower() in ("abort", "aborted", "cancel", "cancelled", "interrupt", "interrupted"):
352
+ return OpenCodeMessage(
353
+ role="assistant",
354
+ content="ABORTED",
355
+ session_id=session_id,
356
+ )
357
+
358
+ # Extract content from the returned parts list if available
359
+ parts = result.get("parts", [])
360
+ content_text = ""
361
+ if isinstance(parts, list):
362
+ text_parts = [
363
+ p.get("text", "")
364
+ for p in parts
365
+ if isinstance(p, dict) and p.get("type") == "text"
366
+ ]
367
+ content_text = "".join(text_parts)
368
+
369
+ # Fallback to standard response text fields if parts are absent/empty
370
+ if not content_text:
371
+ content_text = result.get(
372
+ "content",
373
+ result.get("text", result.get("message", "")),
374
+ )
375
+
376
+ return OpenCodeMessage(
377
+ role=result.get("role", "assistant"),
378
+ content=content_text,
379
+ session_id=session_id,
380
+ tool_calls=result.get("tool_calls", result.get("toolCalls", [])),
381
+ )
382
+
383
+ # Fallback for unexpected shapes.
384
+ return OpenCodeMessage(
385
+ role="assistant",
386
+ content="",
387
+ session_id=session_id,
388
+ )
389
+
390
+ async def share_session(self, session_id: str) -> str:
391
+ """Share a session and retrieve its public URL.
392
+
393
+ Parameters:
394
+ session_id: The session to share.
395
+
396
+ Returns:
397
+ The publicly accessible URL for the shared session.
398
+ """
399
+ result = await self._request(
400
+ "POST",
401
+ f"/session/{session_id}/share",
402
+ )
403
+ url: str = result.get("url", result.get("share_url", str(result)))
404
+ logger.info("Session %s shared: %s", session_id, url)
405
+ return url
406
+
407
+ async def get_available_models(self) -> Dict[str, Any]:
408
+ """Fetch all available models and providers from the server."""
409
+ session = await self._get_session()
410
+ url = f"{self.server_url}/provider"
411
+ headers = {"Accept": "application/json"}
412
+
413
+ async with session.get(url, headers=headers) as resp:
414
+ if resp.status >= 400:
415
+ body = await resp.text()
416
+ raise OpenCodeAPIError(resp.status, body)
417
+
418
+ content_type = resp.headers.get("Content-Type", "")
419
+ if "json" in content_type:
420
+ return await resp.json()
421
+
422
+ text = await resp.text()
423
+ try:
424
+ import json
425
+ return json.loads(text)
426
+ except ValueError:
427
+ return {}
428
+
429
+ async def abort_session(self, session_id: str) -> bool:
430
+ """Send an abort signal to stop active model processing in a session."""
431
+ try:
432
+ result = await self._request("POST", f"/session/{session_id}/abort")
433
+ # If the response indicates success, or we get a successful status code, return True
434
+ if isinstance(result, dict):
435
+ return result.get("success", True)
436
+ return True
437
+ except OpenCodeAPIError as e:
438
+ # If we get a 400/404, it might mean there is nothing active to abort or session is not found
439
+ logger.warning(f"Abort request returned an API error: {e}")
440
+ return False
441
+ except Exception as e:
442
+ logger.error(f"Unexpected error aborting session {session_id}: {e}")
443
+ raise
opencode/server.py ADDED
@@ -0,0 +1,144 @@
1
+ """OpenCode server process manager.
2
+
3
+ Handles starting, stopping, and restarting the `opencode serve` process
4
+ deterministically — when the user switches projects via /project, the server
5
+ is restarted from the new project directory so the AI agent is fully scoped.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import os
11
+ import signal
12
+ import subprocess
13
+ import platform
14
+ import shutil
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _server_process: subprocess.Popen | None = None
19
+ _server_port: int = 8080 # Default port is 8080 from .env
20
+
21
+ def get_opencode_binary() -> str:
22
+ """Find the opencode binary on the system."""
23
+ path = os.environ.get("OPENCODE_BINARY", "")
24
+ if path and os.path.isfile(path):
25
+ return path
26
+ found = shutil.which("opencode")
27
+ if found:
28
+ return found
29
+ return "opencode"
30
+
31
+
32
+ async def restart_server(directory: str, port: int = 8080, hostname: str = "127.0.0.1") -> bool:
33
+ """Stop the running opencode serve process and restart it from *directory*.
34
+
35
+ Returns True if the server came up successfully, False otherwise.
36
+ """
37
+ global _server_process, _server_port
38
+ _server_port = port
39
+
40
+ # 1. Stop the existing server
41
+ await stop_server()
42
+ await asyncio.sleep(1)
43
+
44
+ # 2. Start a new one from the target directory
45
+ binary = get_opencode_binary()
46
+ cmd = [binary, "serve", "--port", str(port), "--hostname", hostname]
47
+
48
+ logger.info(f"Starting opencode serve from {directory}: {' '.join(cmd)}")
49
+
50
+ try:
51
+ creationflags = 0
52
+ if platform.system() == "Windows":
53
+ # CREATE_NEW_PROCESS_GROUP = 0x00000200
54
+ creationflags = 0x00000200
55
+
56
+ _server_process = subprocess.Popen(
57
+ cmd,
58
+ cwd=directory,
59
+ stdout=subprocess.DEVNULL,
60
+ stderr=subprocess.DEVNULL,
61
+ stdin=subprocess.DEVNULL,
62
+ creationflags=creationflags,
63
+ start_new_session=(platform.system() != "Windows"),
64
+ )
65
+ except Exception as e:
66
+ logger.error(f"Failed to start opencode serve: {e}")
67
+ return False
68
+
69
+ # 3. Wait until the server is reachable (max 30s)
70
+ import aiohttp
71
+ for attempt in range(30):
72
+ await asyncio.sleep(1)
73
+ try:
74
+ async with aiohttp.ClientSession() as session:
75
+ async with session.get(f"http://{hostname}:{port}/session", timeout=aiohttp.ClientTimeout(total=2)) as resp:
76
+ if resp.status < 500:
77
+ logger.info(f"opencode serve is up after {attempt + 1}s (pid={_server_process.pid})")
78
+ return True
79
+ except Exception:
80
+ pass
81
+
82
+ logger.error("opencode serve did not become reachable within 30s")
83
+ return False
84
+
85
+
86
+ async def stop_server() -> None:
87
+ """Stop the running opencode serve process (if any)."""
88
+ global _server_process
89
+
90
+ if _server_process is not None and _server_process.poll() is None:
91
+ logger.info(f"Stopping opencode serve (pid={_server_process.pid})")
92
+ try:
93
+ _server_process.terminate()
94
+ try:
95
+ _server_process.wait(timeout=5)
96
+ except subprocess.TimeoutExpired:
97
+ _server_process.kill()
98
+ _server_process.wait(timeout=3)
99
+ except Exception as e:
100
+ logger.warning(f"Error stopping opencode serve: {e}")
101
+ _server_process = None
102
+
103
+ # Also kill any stray opencode serve processes on our port
104
+ if platform.system() == "Windows":
105
+ try:
106
+ # Query netstat to find process ID listening on the port
107
+ cmd = f'netstat -ano | findstr LISTENING | findstr :{_server_port}'
108
+ proc = await asyncio.create_subprocess_shell(
109
+ cmd,
110
+ stdout=asyncio.subprocess.PIPE,
111
+ stderr=asyncio.subprocess.DEVNULL
112
+ )
113
+ stdout, _ = await proc.communicate()
114
+ lines = stdout.decode().strip().split('\n')
115
+ for line in lines:
116
+ parts = line.strip().split()
117
+ if len(parts) >= 5:
118
+ pid = parts[-1]
119
+ if pid.isdigit() and int(pid) > 0:
120
+ os.system(f"taskkill /F /T /PID {pid}")
121
+ logger.info(f"Killed stray Windows PID={pid} on port {_server_port}")
122
+ except Exception as e:
123
+ logger.warning(f"Failed to kill stray Windows process: {e}")
124
+ else:
125
+ # Unix lsof implementation
126
+ try:
127
+ proc = await asyncio.create_subprocess_exec(
128
+ "lsof", "-ti", f":{_server_port}", "-sTCP:LISTEN",
129
+ stdout=asyncio.subprocess.PIPE,
130
+ stderr=asyncio.subprocess.DEVNULL,
131
+ )
132
+ stdout, _ = await proc.communicate()
133
+ pids = stdout.decode().strip().split()
134
+ for pid in pids:
135
+ if pid.isdigit():
136
+ try:
137
+ os.kill(int(pid), signal.SIGTERM)
138
+ logger.info(f"Killed stray Unix PID={pid} on port {_server_port}")
139
+ except ProcessLookupError:
140
+ pass
141
+ except Exception as e:
142
+ logger.warning(f"Error calling lsof: {e}")
143
+
144
+ await asyncio.sleep(0.5)
sessions/__init__.py ADDED
@@ -0,0 +1 @@
1
+