opencode-py 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.
@@ -0,0 +1,255 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from typing import Any, AsyncIterator, Dict, Optional
5
+
6
+ from opencode._async_client import AsyncOpendcodeClient
7
+ from opencode._async_session import AsyncSession
8
+ from opencode._models import SessionMessage
9
+ from opencode._opencode import _extract_text, _resolve_model
10
+ from opencode._server import OpencodeServer, create_opencode_server
11
+
12
+ _async_opencode_state: Dict[str, Any] = {}
13
+
14
+
15
+ class AsyncOpendcode:
16
+ def __init__(
17
+ self,
18
+ *,
19
+ model: Optional[str] = None,
20
+ hostname: str = "127.0.0.1",
21
+ port: int = 4096,
22
+ directory: Optional[str] = None,
23
+ workspace: Optional[str] = None,
24
+ server_timeout: float = 30.0,
25
+ client_timeout: float = 300.0,
26
+ config: Optional[Dict[str, Any]] = None,
27
+ opencode_binary: Optional[str] = None,
28
+ ):
29
+ self._model = model
30
+ self._hostname = hostname
31
+ self._port = port
32
+ self._directory = directory
33
+ self._workspace = workspace
34
+ self._server_timeout = server_timeout
35
+ self._client_timeout = client_timeout
36
+ self._config = config
37
+ self._opencode_binary = opencode_binary
38
+
39
+ self._server: Optional[OpencodeServer] = None
40
+ self._client: Optional[AsyncOpendcodeClient] = None
41
+
42
+ # ------------------------------------------------------------------
43
+ # Async context manager
44
+ # ------------------------------------------------------------------
45
+
46
+ async def __aenter__(self) -> AsyncOpendcode:
47
+ self.start()
48
+ return self
49
+
50
+ async def __aexit__(self, *args: Any) -> None:
51
+ await self.close()
52
+
53
+ # ------------------------------------------------------------------
54
+ # Server / Client lifecycle
55
+ # ------------------------------------------------------------------
56
+
57
+ def start(self) -> None:
58
+ if self._client is not None:
59
+ return
60
+ server = create_opencode_server(
61
+ hostname=self._hostname,
62
+ port=self._port,
63
+ timeout=self._server_timeout,
64
+ config=self._config,
65
+ opencode_binary=self._opencode_binary,
66
+ )
67
+ client = AsyncOpendcodeClient(
68
+ base_url=server.url,
69
+ directory=self._directory,
70
+ workspace=self._workspace,
71
+ timeout=self._client_timeout,
72
+ )
73
+ self._server = server
74
+ self._client = client
75
+
76
+ async def close(self) -> None:
77
+ if self._client:
78
+ await self._client.close()
79
+ self._client = None
80
+ if self._server:
81
+ self._server.close()
82
+ self._server = None
83
+
84
+ @property
85
+ def client(self) -> AsyncOpendcodeClient:
86
+ assert self._client is not None
87
+ return self._client
88
+
89
+ @property
90
+ def server(self) -> OpencodeServer:
91
+ assert self._server is not None
92
+ return self._server
93
+
94
+ # ------------------------------------------------------------------
95
+ # High-level API
96
+ # ------------------------------------------------------------------
97
+
98
+ async def create_session(self, agent: Optional[str] = None, **kwargs) -> AsyncSession:
99
+ if agent:
100
+ kwargs["agent"] = agent
101
+ raw = await self.client.session_create(**kwargs)
102
+ sid = raw["id"]
103
+ return AsyncSession(self.client, sid)
104
+
105
+ async def ask(
106
+ self,
107
+ prompt: str,
108
+ *,
109
+ files: Optional[Dict[str, Any]] = None,
110
+ auto_tools: bool = False,
111
+ agent: Optional[str] = None,
112
+ format: Optional[Dict[str, Any]] = None,
113
+ wait: bool = True,
114
+ poll_interval: float = 0.5,
115
+ poll_timeout: float = 600.0,
116
+ ) -> str:
117
+ session = await self.create_session(agent=agent)
118
+ model = _resolve_model(model=self._model, config=self._config)
119
+ if auto_tools:
120
+ from opencode._tools import ToolExecutor
121
+
122
+ msg = await session.ask(
123
+ prompt,
124
+ files=files,
125
+ model=model,
126
+ format=format,
127
+ tool_executor=ToolExecutor(),
128
+ )
129
+ else:
130
+ msg = await session.prompt(
131
+ prompt,
132
+ files=files,
133
+ wait=wait,
134
+ model=model,
135
+ format=format,
136
+ poll_interval=poll_interval,
137
+ poll_timeout=poll_timeout,
138
+ )
139
+ return _extract_text(msg)
140
+
141
+ async def ask_stream(
142
+ self,
143
+ prompt: str,
144
+ *,
145
+ files: Optional[Dict[str, Any]] = None,
146
+ session: Optional[AsyncSession] = None,
147
+ ) -> AsyncIterator[str]:
148
+ import json
149
+
150
+ import httpx
151
+
152
+ if session is None:
153
+ session = await self.create_session()
154
+ # Use V1 synchronous prompt — events arrive via /event
155
+ body: Dict[str, Any] = {"parts": [{"type": "text", "text": prompt}]}
156
+ resolved = _resolve_model(model=self._model, config=self._config)
157
+ if resolved:
158
+ body["model"] = resolved
159
+
160
+ # Subscribe before sending to catch all events
161
+ response = await self.client.event_subscribe()
162
+ assert isinstance(response, httpx.Response)
163
+
164
+ # Send prompt (V1 sync)
165
+ await self.client.session_send(session.id, body)
166
+
167
+ seen_parts: set[str] = set()
168
+
169
+ async for line in response.aiter_lines():
170
+ if not line.startswith("data: "):
171
+ continue
172
+ payload = json.loads(line[6:])
173
+ event_type = payload.get("type")
174
+ props = payload.get("properties", {})
175
+
176
+ # Skip events for other sessions
177
+ sid = props.get("sessionID")
178
+ if sid is not None and sid != session.id:
179
+ continue
180
+
181
+ if event_type == "message.part.delta":
182
+ if props.get("field") == "text":
183
+ part_id = props.get("partID", "")
184
+ delta = props.get("delta", "")
185
+ if delta:
186
+ seen_parts.add(part_id)
187
+ yield delta
188
+
189
+ elif event_type == "message.part.updated":
190
+ part = props.get("part", {})
191
+ part_id = part.get("id", "")
192
+ if part_id not in seen_parts and part.get("type") == "text":
193
+ text = part.get("text", "")
194
+ if text:
195
+ seen_parts.add(part_id)
196
+ yield text
197
+
198
+ elif event_type == "session.status":
199
+ status = props.get("status", {})
200
+ if isinstance(status, dict) and status.get("type") == "idle":
201
+ break
202
+
203
+ elif event_type == "session.idle":
204
+ break
205
+
206
+
207
+ async def async_opencode(
208
+ prompt: str,
209
+ *,
210
+ keep: bool = False,
211
+ auto_tools: bool = False,
212
+ agent: Optional[str] = None,
213
+ model: Optional[str] = None,
214
+ format: Optional[Dict[str, Any]] = None,
215
+ port: int = 4096,
216
+ directory: Optional[str] = None,
217
+ config: Optional[Dict[str, Any]] = None,
218
+ ) -> str:
219
+ global _async_opencode_state
220
+ state = _async_opencode_state
221
+
222
+ if not state:
223
+ cfg = dict(config or {})
224
+ resolved_agent = agent or ("build" if auto_tools else None)
225
+ ai = AsyncOpendcode(port=port, directory=directory, config=cfg or None, model=model)
226
+ ai.start()
227
+ session = await ai.create_session(agent=resolved_agent)
228
+ state["ai"] = ai
229
+ state["session"] = session
230
+ state["config"] = cfg
231
+ state["model"] = model
232
+ else:
233
+ ai = state["ai"]
234
+ session = state["session"]
235
+ if model is not None or config is not None:
236
+ new_cfg = dict(config or {})
237
+ if model is not None and model != state.get("model"):
238
+ warnings.warn("Ignoring new model — using existing session")
239
+ elif new_cfg != state.get("config", {}):
240
+ warnings.warn("Ignoring new config — using existing session")
241
+
242
+ resolved = _resolve_model(model=state.get("model"), config=state.get("config", {}))
243
+ if auto_tools:
244
+ from opencode._tools import ToolExecutor
245
+
246
+ msg = await session.ask(prompt, model=resolved, format=format, tool_executor=ToolExecutor())
247
+ else:
248
+ msg = await session.prompt(prompt, model=resolved, format=format)
249
+ result = _extract_text(msg)
250
+
251
+ if not keep:
252
+ await ai.close()
253
+ state.clear()
254
+
255
+ return result
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from opencode._async_client import AsyncOpendcodeClient
6
+ from opencode._models import SessionMessage
7
+ from opencode._tools import ToolExecutor
8
+
9
+
10
+ class AsyncSession:
11
+ def __init__(self, client: AsyncOpendcodeClient, session_id: str):
12
+ self._client = client
13
+ self.id = session_id
14
+ self._auto_confirmed: bool = False
15
+
16
+ async def prompt(
17
+ self,
18
+ text: str,
19
+ *,
20
+ files: Optional[List[Dict[str, Any]]] = None,
21
+ agents: Optional[List[Dict[str, Any]]] = None,
22
+ references: Optional[List[Dict[str, Any]]] = None,
23
+ model: Optional[Dict[str, str]] = None,
24
+ format: Optional[Dict[str, Any]] = None,
25
+ wait: bool = True,
26
+ poll_interval: float = 0.5,
27
+ poll_timeout: float = 600.0,
28
+ ) -> SessionMessage:
29
+ parts: List[Dict[str, Any]] = [{"type": "text", "text": text}]
30
+ body: Dict[str, Any] = {"parts": parts}
31
+ if model:
32
+ body["model"] = model
33
+ if format:
34
+ body["format"] = format
35
+
36
+ result = await self._client.session_send(self.id, body)
37
+
38
+ if isinstance(result, dict):
39
+ parts_list = result.get("parts", [])
40
+ info = result.get("info", {})
41
+ structured = result.get("structured") or info.get("structured")
42
+ text_parts: List[Dict[str, Any]] = []
43
+ for p in parts_list:
44
+ ptype = p.get("type", "")
45
+ if ptype in ("text", "reasoning", "tool"):
46
+ text_parts.append({"type": ptype, "text": p.get("text", "")})
47
+ msg: Dict[str, Any] = {
48
+ "id": info.get("id", ""),
49
+ "type": "assistant",
50
+ "content": text_parts,
51
+ "model": info.get("model"),
52
+ "time": info.get("time", {}),
53
+ }
54
+ if structured is not None:
55
+ msg["structured"] = structured
56
+ return msg
57
+
58
+ return result
59
+
60
+ async def ask(
61
+ self,
62
+ text: str,
63
+ *,
64
+ files: Optional[List[Dict[str, Any]]] = None,
65
+ model: Optional[Dict[str, str]] = None,
66
+ format: Optional[Dict[str, Any]] = None,
67
+ max_tool_rounds: int = 25,
68
+ tool_executor: Optional[ToolExecutor] = None,
69
+ quiet: bool = False,
70
+ ) -> SessionMessage:
71
+ if tool_executor is None:
72
+ tool_executor = ToolExecutor()
73
+ parts: List[Dict[str, Any]] = [{"type": "text", "text": text}]
74
+ if files:
75
+ parts.extend(files)
76
+
77
+ for _round in range(max_tool_rounds):
78
+ body: Dict[str, Any] = {"parts": parts}
79
+ if model:
80
+ body["model"] = model
81
+ if format:
82
+ body["format"] = format
83
+
84
+ result = await self._client.session_send(self.id, body)
85
+ if not isinstance(result, dict):
86
+ return result
87
+
88
+ parts_list = result.get("parts", [])
89
+ info = result.get("info", {})
90
+
91
+ tool_uses = [p for p in parts_list if p.get("type") == "tool-use"]
92
+ if tool_uses:
93
+ self._auto_confirmed = True
94
+ results: List[Dict[str, Any]] = []
95
+ for tu in tool_uses:
96
+ tool_name = tu.get("tool", {}).get("name", "")
97
+ tool_input = tu.get("tool", {}).get("input", {})
98
+ tool_id = tu.get("toolUseID", "")
99
+ if not quiet:
100
+ print(f"\033[33m[Tool] {tool_name}\033[0m")
101
+ output = tool_executor.execute(tool_name, tool_input)
102
+ results.append({
103
+ "type": "tool-result",
104
+ "toolUseID": tool_id,
105
+ "tool": {"name": tool_name},
106
+ "output": output,
107
+ })
108
+
109
+ parts = results
110
+ continue
111
+
112
+ if not self._auto_confirmed:
113
+ self._auto_confirmed = True
114
+ parts = [{"type": "text", "text": "Exit plan mode and proceed with execution now. Use tools as needed."}]
115
+ continue
116
+
117
+ text_parts: List[Dict[str, Any]] = []
118
+ for p in parts_list:
119
+ ptype = p.get("type", "")
120
+ if ptype in ("text", "reasoning", "tool"):
121
+ text_parts.append({"type": ptype, "text": p.get("text", "")})
122
+ msg: Dict[str, Any] = {
123
+ "id": info.get("id", ""),
124
+ "type": "assistant",
125
+ "content": text_parts,
126
+ "model": info.get("model"),
127
+ "time": info.get("time", {}),
128
+ }
129
+ structured = result.get("structured") or info.get("structured")
130
+ if structured is not None:
131
+ msg["structured"] = structured
132
+ return msg
133
+
134
+ raise RuntimeError(f"Tool loop exceeded {max_tool_rounds} rounds")
135
+
136
+ async def messages(self, **kwargs) -> Any:
137
+ return await self._client.v2_session_messages(self.id, **kwargs)
138
+
139
+ async def context(self, **kwargs) -> List[SessionMessage]:
140
+ return await self._client.v2_session_context(self.id, **kwargs)
141
+
142
+ async def compact(self) -> Any:
143
+ return await self._client.v2_session_compact(self.id)
144
+
145
+ async def abort(self) -> Any:
146
+ return await self._client.session_abort(self.id)
147
+
148
+ async def fork(self, **kwargs) -> Any:
149
+ return await self._client.session_fork(self.id, **kwargs)
150
+
151
+ async def diff(self) -> Any:
152
+ return await self._client.session_diff(self.id)
153
+
154
+ async def todo(self) -> Any:
155
+ return await self._client.session_todo(self.id)
opencode/_binary.py ADDED
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import platform
5
+ import re
6
+ import shutil
7
+ import stat
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from opencode._errors import BinaryNotFound
13
+
14
+
15
+ def _system() -> str:
16
+ raw = platform.system().lower()
17
+ if raw == "darwin":
18
+ return "darwin"
19
+ if raw == "windows":
20
+ return "win32"
21
+ return "linux"
22
+
23
+
24
+ def _arch() -> str:
25
+ raw = platform.machine().lower()
26
+ if raw in ("amd64", "x86_64"):
27
+ return "x64"
28
+ if raw in ("aarch64", "arm64"):
29
+ return "arm64"
30
+ return raw
31
+
32
+
33
+ def _platform_suffix() -> str:
34
+ return f"{_system()}-{_arch()}"
35
+
36
+
37
+ def _resolve_wrapper(path: str) -> str:
38
+ """If *path* is a .cmd/.bat wrapper (npm-style), return the real .exe it launches."""
39
+ if not path.lower().endswith((".cmd", ".bat")):
40
+ return path
41
+ try:
42
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
43
+ for line in text.splitlines():
44
+ m = re.search(r'"([^"]+\.exe)"', line)
45
+ if m:
46
+ rel = m.group(1)
47
+ full = rel.replace("%dp0%", os.path.dirname(path)).replace("/", "\\")
48
+ if os.path.isfile(full):
49
+ return os.path.realpath(full)
50
+ except Exception:
51
+ pass
52
+ return path
53
+
54
+
55
+ def find_in_path(name: str = "opencode") -> Optional[str]:
56
+ resolved = shutil.which(name)
57
+ if resolved:
58
+ return _resolve_wrapper(resolved)
59
+ if sys.platform == "win32":
60
+ for ext in (".exe", ".cmd", ".bat"):
61
+ resolved = shutil.which(name + ext)
62
+ if resolved:
63
+ return _resolve_wrapper(resolved)
64
+ return None
65
+
66
+
67
+ def binary_dir() -> Path:
68
+ return Path.home() / ".opencode" / "bin"
69
+
70
+
71
+ def find_local(name: str = "opencode") -> Optional[str]:
72
+ candidates = [name]
73
+ if sys.platform == "win32":
74
+ candidates = [f"{name}.exe", name]
75
+ for candidate in candidates:
76
+ full = binary_dir() / candidate
77
+ if full.exists():
78
+ return str(full.resolve())
79
+ return None
80
+
81
+
82
+ def ensure_opencode(name: str = "opencode") -> str:
83
+ existing = find_in_path(name)
84
+ if existing:
85
+ return existing
86
+ existing = find_local(name)
87
+ if existing:
88
+ return existing
89
+ path = download_opencode(name)
90
+ return path
91
+
92
+
93
+ def download_opencode(
94
+ name: str = "opencode",
95
+ version: str = "latest",
96
+ dest: Optional[Path] = None,
97
+ ) -> str:
98
+ import io
99
+ import json
100
+ import tarfile
101
+ import urllib.request
102
+ import zipfile
103
+
104
+ dest = dest or binary_dir()
105
+ dest.mkdir(parents=True, exist_ok=True)
106
+ suffix = _platform_suffix()
107
+
108
+ if version == "latest":
109
+ url = "https://api.github.com/repos/anomalyco/opencode/releases/latest"
110
+ req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "opencode-py"})
111
+ with urllib.request.urlopen(req) as resp:
112
+ data = json.loads(resp.read().decode())
113
+ version = data["tag_name"]
114
+
115
+ ext = ".zip" if sys.platform == "win32" else ".tar.gz"
116
+ archive_url = f"https://github.com/anomalyco/opencode/releases/download/{version}/opencode-{suffix}{ext}"
117
+
118
+ print(f"Downloading opencode {version} ({suffix})...")
119
+ req = urllib.request.Request(archive_url, headers={"User-Agent": "opencode-py"})
120
+ with urllib.request.urlopen(req) as resp:
121
+ body = resp.read()
122
+
123
+ if ext == ".zip":
124
+ zf = zipfile.ZipFile(io.BytesIO(body))
125
+ zf.extractall(str(dest))
126
+ else:
127
+ tf = tarfile.open(fileobj=io.BytesIO(body), mode="r:gz")
128
+ tf.extractall(str(dest))
129
+
130
+ final = dest / (name + (".exe" if sys.platform == "win32" else ""))
131
+ if final.exists():
132
+ final.chmod(final.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
133
+
134
+ print(f"Downloaded opencode to {final}")
135
+ return str(final.resolve())