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.
opencode/_opencode.py ADDED
@@ -0,0 +1,290 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import warnings
5
+ from typing import Any, Dict, Iterator, List, Optional
6
+
7
+ from opencode._client import OpencodeClient
8
+ from opencode._models import SessionMessage
9
+ from opencode._server import OpencodeServer, create_opencode_server
10
+ from opencode._session import Session
11
+
12
+ _opencode_state: Dict[str, Any] = {}
13
+
14
+
15
+ def _resolve_model(
16
+ model: Optional[str] = None,
17
+ config: Optional[Dict[str, Any]] = None,
18
+ ) -> Optional[Dict[str, str]]:
19
+ model_str = model
20
+ if not model_str and config:
21
+ model_str = config.get("model")
22
+ if not model_str:
23
+ return None
24
+ if "/" in model_str:
25
+ provider, model_id = model_str.split("/", 1)
26
+ return {"providerID": provider, "modelID": model_id}
27
+ return {"providerID": "opencode", "modelID": model_str}
28
+
29
+
30
+ class Opencode:
31
+ def __init__(
32
+ self,
33
+ *,
34
+ model: Optional[str] = None,
35
+ hostname: str = "127.0.0.1",
36
+ port: int = 4096,
37
+ directory: Optional[str] = None,
38
+ workspace: Optional[str] = None,
39
+ server_timeout: float = 30.0,
40
+ client_timeout: float = 300.0,
41
+ config: Optional[Dict[str, Any]] = None,
42
+ opencode_binary: Optional[str] = None,
43
+ ):
44
+ self._model = model
45
+ self._hostname = hostname
46
+ self._port = port
47
+ self._directory = directory
48
+ self._workspace = workspace
49
+ self._server_timeout = server_timeout
50
+ self._client_timeout = client_timeout
51
+ self._config = config
52
+ self._opencode_binary = opencode_binary
53
+
54
+ self._server: Optional[OpencodeServer] = None
55
+ self._client: Optional[OpencodeClient] = None
56
+
57
+ # ------------------------------------------------------------------
58
+ # Context manager
59
+ # ------------------------------------------------------------------
60
+
61
+ def __enter__(self) -> Opencode:
62
+ self.start()
63
+ return self
64
+
65
+ def __exit__(self, *args: Any) -> None:
66
+ self.close()
67
+
68
+ # ------------------------------------------------------------------
69
+ # Server / Client lifecycle
70
+ # ------------------------------------------------------------------
71
+
72
+ def start(self) -> None:
73
+ if self._client is not None:
74
+ return
75
+ server = create_opencode_server(
76
+ hostname=self._hostname,
77
+ port=self._port,
78
+ timeout=self._server_timeout,
79
+ config=self._config,
80
+ opencode_binary=self._opencode_binary,
81
+ )
82
+ client = OpencodeClient(
83
+ base_url=server.url,
84
+ directory=self._directory,
85
+ workspace=self._workspace,
86
+ timeout=self._client_timeout,
87
+ )
88
+ self._server = server
89
+ self._client = client
90
+
91
+ def close(self) -> None:
92
+ if self._client:
93
+ self._client.close()
94
+ self._client = None
95
+ if self._server:
96
+ self._server.close()
97
+ self._server = None
98
+
99
+ @property
100
+ def client(self) -> OpencodeClient:
101
+ self.start()
102
+ assert self._client is not None
103
+ return self._client
104
+
105
+ @property
106
+ def server(self) -> OpencodeServer:
107
+ self.start()
108
+ assert self._server is not None
109
+ return self._server
110
+
111
+ # ------------------------------------------------------------------
112
+ # High-level API
113
+ # ------------------------------------------------------------------
114
+
115
+ def create_session(self, agent: Optional[str] = None, **kwargs) -> Session:
116
+ if agent:
117
+ kwargs["agent"] = agent
118
+ raw = self.client.session_create(**kwargs)
119
+ sid = raw["id"]
120
+ return Session(self.client, sid)
121
+
122
+ def _resolve_model(self) -> Optional[Dict[str, str]]:
123
+ return _resolve_model(model=self._model, config=self._config)
124
+
125
+ def ask(
126
+ self,
127
+ prompt: str,
128
+ *,
129
+ files: Optional[List[Dict[str, Any]]] = None,
130
+ auto_tools: bool = False,
131
+ agent: Optional[str] = None,
132
+ format: Optional[Dict[str, Any]] = None,
133
+ wait: bool = True,
134
+ poll_interval: float = 0.5,
135
+ poll_timeout: float = 600.0,
136
+ ) -> str:
137
+ session = self.create_session(agent=agent)
138
+ if auto_tools:
139
+ from opencode._tools import ToolExecutor
140
+
141
+ msg = session.ask(
142
+ prompt,
143
+ files=files,
144
+ model=self._resolve_model(),
145
+ format=format,
146
+ tool_executor=ToolExecutor(),
147
+ )
148
+ else:
149
+ msg = session.prompt(
150
+ prompt,
151
+ files=files,
152
+ wait=wait,
153
+ model=self._resolve_model(),
154
+ format=format,
155
+ poll_interval=poll_interval,
156
+ poll_timeout=poll_timeout,
157
+ )
158
+ return _extract_text(msg)
159
+
160
+ def ask_stream(
161
+ self,
162
+ prompt: str,
163
+ *,
164
+ files: Optional[List[Dict[str, Any]]] = None,
165
+ session: Optional[Session] = None,
166
+ ) -> Iterator[str]:
167
+ import json
168
+
169
+ import httpx
170
+
171
+ if session is None:
172
+ session = self.create_session()
173
+ # Use V1 synchronous prompt — the response events arrive via /event
174
+ body: Dict[str, Any] = {"parts": [{"type": "text", "text": prompt}]}
175
+ resolved = self._resolve_model()
176
+ if resolved:
177
+ body["model"] = resolved
178
+
179
+ # Subscribe before sending to catch all events
180
+ response = self.client.event_subscribe()
181
+ assert isinstance(response, httpx.Response)
182
+
183
+ # Send prompt (V1 sync)
184
+ self.client.session_send(session.id, body)
185
+
186
+ seen_parts: set[str] = set()
187
+
188
+ for line in response.iter_lines():
189
+ if not line.startswith("data: "):
190
+ continue
191
+ payload = json.loads(line[6:])
192
+ event_type = payload.get("type")
193
+ props = payload.get("properties", {})
194
+
195
+ # Skip events for other sessions
196
+ sid = props.get("sessionID")
197
+ if sid is not None and sid != session.id:
198
+ continue
199
+
200
+ if event_type == "message.part.delta":
201
+ if props.get("field") == "text":
202
+ part_id = props.get("partID", "")
203
+ delta = props.get("delta", "")
204
+ if delta:
205
+ seen_parts.add(part_id)
206
+ yield delta
207
+
208
+ elif event_type == "message.part.updated":
209
+ part = props.get("part", {})
210
+ part_id = part.get("id", "")
211
+ if part_id not in seen_parts and part.get("type") == "text":
212
+ text = part.get("text", "")
213
+ if text:
214
+ seen_parts.add(part_id)
215
+ yield text
216
+
217
+ elif event_type == "session.status":
218
+ status = props.get("status", {})
219
+ if isinstance(status, dict) and status.get("type") == "idle":
220
+ break
221
+
222
+ elif event_type == "session.idle":
223
+ break
224
+
225
+
226
+ def _extract_text(msg: SessionMessage) -> str:
227
+ if isinstance(msg, dict) and msg.get("type") == "assistant":
228
+ structured = msg.get("structured")
229
+ if structured is not None:
230
+ return json.dumps(structured, ensure_ascii=False, default=str)
231
+ parts = msg.get("content", [])
232
+ texts: List[str] = []
233
+ for part in parts:
234
+ if isinstance(part, dict) and part.get("type") == "text":
235
+ texts.append(part.get("text", ""))
236
+ return "\n".join(texts)
237
+ if isinstance(msg, dict) and msg.get("type") == "user":
238
+ return msg.get("text", "")
239
+ return str(msg)
240
+
241
+
242
+ def opencode(
243
+ prompt: str,
244
+ *,
245
+ keep: bool = False,
246
+ auto_tools: bool = False,
247
+ agent: Optional[str] = None,
248
+ model: Optional[str] = None,
249
+ format: Optional[Dict[str, Any]] = None,
250
+ port: int = 4096,
251
+ directory: Optional[str] = None,
252
+ config: Optional[Dict[str, Any]] = None,
253
+ ) -> str:
254
+ global _opencode_state
255
+ state = _opencode_state
256
+
257
+ if not state:
258
+ cfg = dict(config or {})
259
+ resolved_agent = agent or ("build" if auto_tools else None)
260
+ ai = Opencode(port=port, directory=directory, config=cfg or None, model=model)
261
+ ai.start()
262
+ session = ai.create_session(agent=resolved_agent)
263
+ state["ai"] = ai
264
+ state["session"] = session
265
+ state["config"] = cfg
266
+ state["model"] = model
267
+ else:
268
+ ai = state["ai"]
269
+ session = state["session"]
270
+ if model is not None or config is not None:
271
+ new_cfg = dict(config or {})
272
+ if model is not None and model != state.get("model"):
273
+ warnings.warn("Ignoring new model — using existing session")
274
+ elif new_cfg != state.get("config", {}):
275
+ warnings.warn("Ignoring new config — using existing session")
276
+
277
+ resolved = _resolve_model(model=state.get("model"), config=state.get("config", {}))
278
+ if auto_tools:
279
+ from opencode._tools import ToolExecutor
280
+
281
+ msg = session.ask(prompt, model=resolved, format=format, tool_executor=ToolExecutor())
282
+ else:
283
+ msg = session.prompt(prompt, model=resolved, format=format)
284
+ result = _extract_text(msg)
285
+
286
+ if not keep:
287
+ ai.close()
288
+ state.clear()
289
+
290
+ return result
opencode/_process.py ADDED
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import signal
4
+ import subprocess
5
+ import sys
6
+
7
+
8
+ def stop(proc: subprocess.Popen) -> None:
9
+ if proc.poll() is not None:
10
+ return
11
+ if sys.platform == "win32" and proc.pid:
12
+ try:
13
+ subprocess.run(
14
+ ["taskkill", "/pid", str(proc.pid), "/T", "/F"],
15
+ capture_output=True,
16
+ timeout=5,
17
+ )
18
+ return
19
+ except Exception:
20
+ pass
21
+ try:
22
+ proc.send_signal(signal.SIGTERM)
23
+ except Exception:
24
+ pass
opencode/_server.py ADDED
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from typing import Any, Dict, Optional
10
+
11
+ from opencode._binary import ensure_opencode
12
+ from opencode._errors import ServerStartupTimeout
13
+ from opencode._process import stop
14
+
15
+
16
+ class OpencodeServer:
17
+ def __init__(self, proc: subprocess.Popen, url: str, binary: str):
18
+ self._proc = proc
19
+ self.url = url
20
+ self.binary = binary
21
+
22
+ def close(self) -> None:
23
+ stop(self._proc)
24
+
25
+ @property
26
+ def pid(self) -> Optional[int]:
27
+ return self._proc.pid
28
+
29
+ @property
30
+ def running(self) -> bool:
31
+ return self._proc.poll() is None
32
+
33
+
34
+ def create_opencode_server(
35
+ *,
36
+ hostname: str = "127.0.0.1",
37
+ port: int = 4096,
38
+ timeout: float = 30.0,
39
+ config: Optional[Dict[str, Any]] = None,
40
+ opencode_binary: Optional[str] = None,
41
+ ) -> OpencodeServer:
42
+ binary = opencode_binary or ensure_opencode()
43
+
44
+ args = [binary, "serve", f"--hostname={hostname}", f"--port={port}"]
45
+ env = os.environ.copy()
46
+ if config:
47
+ env["OPENCODE_CONFIG_CONTENT"] = json.dumps(config)
48
+
49
+ proc = subprocess.Popen(
50
+ args,
51
+ stdout=subprocess.PIPE,
52
+ stderr=subprocess.PIPE,
53
+ env=env,
54
+ )
55
+
56
+ start_time = time.monotonic()
57
+ output = ""
58
+ url: Optional[str] = None
59
+
60
+ while time.monotonic() - start_time < timeout:
61
+ line = proc.stdout.readline() if proc.stdout else b""
62
+ if not line:
63
+ if proc.poll() is not None:
64
+ stderr_output = ""
65
+ if proc.stderr:
66
+ stderr_output = proc.stderr.read().decode("utf-8", errors="replace")
67
+ raise RuntimeError(
68
+ f"opencode exited with code {proc.returncode}"
69
+ f"\nstdout: {output}"
70
+ f"\nstderr: {stderr_output}"
71
+ )
72
+ time.sleep(0.05)
73
+ continue
74
+
75
+ decoded = line.decode("utf-8", errors="replace").strip()
76
+ output += decoded + "\n"
77
+
78
+ m = re.search(r"on\s+(https?://[^\s]+)", decoded)
79
+ if m:
80
+ url = m.group(1)
81
+ break
82
+
83
+ if not url:
84
+ stop(proc)
85
+ stderr_output = ""
86
+ if proc.stderr:
87
+ try:
88
+ stderr_output = proc.stderr.read().decode("utf-8", errors="replace")
89
+ except Exception:
90
+ pass
91
+ raise ServerStartupTimeout(
92
+ f"Timeout waiting for opencode server after {timeout}s"
93
+ f"\nstdout: {output}"
94
+ f"\nstderr: {stderr_output}",
95
+ )
96
+
97
+ return OpencodeServer(proc, url, binary)
opencode/_session.py ADDED
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from opencode._client import OpencodeClient
7
+ from opencode._models import SessionMessage
8
+ from opencode._tools import ToolExecutor
9
+
10
+
11
+ class Session:
12
+ def __init__(self, client: OpencodeClient, session_id: str):
13
+ self._client = client
14
+ self.id = session_id
15
+ self._auto_confirmed: bool = False
16
+
17
+ def prompt(
18
+ self,
19
+ text: str,
20
+ *,
21
+ files: Optional[List[Dict[str, Any]]] = None,
22
+ agents: Optional[List[Dict[str, Any]]] = None,
23
+ references: Optional[List[Dict[str, Any]]] = None,
24
+ model: Optional[Dict[str, str]] = None,
25
+ format: Optional[Dict[str, Any]] = None,
26
+ wait: bool = True,
27
+ poll_interval: float = 0.5,
28
+ poll_timeout: float = 600.0,
29
+ ) -> SessionMessage:
30
+ parts: List[Dict[str, Any]] = [{"type": "text", "text": text}]
31
+ body: Dict[str, Any] = {"parts": parts}
32
+ if model:
33
+ body["model"] = model
34
+ if format:
35
+ body["format"] = format
36
+
37
+ # Use V1 sync prompt (POST /session/:id/message)
38
+ result = self._client.session_send(self.id, body)
39
+
40
+ if isinstance(result, dict):
41
+ parts_list = result.get("parts", [])
42
+ info = result.get("info", {})
43
+ structured = result.get("structured") or info.get("structured")
44
+ # Convert parts to V2-like SessionMessage format
45
+ text_parts: List[Dict[str, Any]] = []
46
+ for p in parts_list:
47
+ ptype = p.get("type", "")
48
+ if ptype in ("text", "reasoning", "tool"):
49
+ text_parts.append({"type": ptype, "text": p.get("text", "")})
50
+ msg: Dict[str, Any] = {
51
+ "id": info.get("id", ""),
52
+ "type": "assistant",
53
+ "content": text_parts,
54
+ "model": info.get("model"),
55
+ "time": info.get("time", {}),
56
+ }
57
+ if structured is not None:
58
+ msg["structured"] = structured
59
+ return msg
60
+
61
+ return result
62
+
63
+ def ask(
64
+ self,
65
+ text: str,
66
+ *,
67
+ files: Optional[List[Dict[str, Any]]] = None,
68
+ model: Optional[Dict[str, str]] = None,
69
+ format: Optional[Dict[str, Any]] = None,
70
+ max_tool_rounds: int = 25,
71
+ tool_executor: Optional[ToolExecutor] = None,
72
+ quiet: bool = False,
73
+ ) -> SessionMessage:
74
+ if tool_executor is None:
75
+ tool_executor = ToolExecutor()
76
+ parts: List[Dict[str, Any]] = [{"type": "text", "text": text}]
77
+ if files:
78
+ parts.extend(files)
79
+
80
+ for _round in range(max_tool_rounds):
81
+ body: Dict[str, Any] = {"parts": parts}
82
+ if model:
83
+ body["model"] = model
84
+ if format:
85
+ body["format"] = format
86
+
87
+ result = self._client.session_send(self.id, body)
88
+ if not isinstance(result, dict):
89
+ return result
90
+
91
+ parts_list = result.get("parts", [])
92
+ info = result.get("info", {})
93
+
94
+ tool_uses = [p for p in parts_list if p.get("type") == "tool-use"]
95
+ if tool_uses:
96
+ self._auto_confirmed = True
97
+ results: List[Dict[str, Any]] = []
98
+ for tu in tool_uses:
99
+ tool_name = tu.get("tool", {}).get("name", "")
100
+ tool_input = tu.get("tool", {}).get("input", {})
101
+ tool_id = tu.get("toolUseID", "")
102
+ if not quiet:
103
+ print(f"\033[33m[Tool] {tool_name}\033[0m")
104
+ output = tool_executor.execute(tool_name, tool_input)
105
+ results.append({
106
+ "type": "tool-result",
107
+ "toolUseID": tool_id,
108
+ "tool": {"name": tool_name},
109
+ "output": output,
110
+ })
111
+
112
+ parts = results
113
+ continue
114
+
115
+ # No tool-use parts — model may be planning or asking.
116
+ if not self._auto_confirmed:
117
+ self._auto_confirmed = True
118
+ parts = [{"type": "text", "text": "Exit plan mode and proceed with execution now. Use tools as needed."}]
119
+ continue
120
+
121
+ # Final response (no tool calls, already confirmed)
122
+ text_parts: List[Dict[str, Any]] = []
123
+ for p in parts_list:
124
+ ptype = p.get("type", "")
125
+ if ptype in ("text", "reasoning", "tool"):
126
+ text_parts.append({"type": ptype, "text": p.get("text", "")})
127
+ msg: Dict[str, Any] = {
128
+ "id": info.get("id", ""),
129
+ "type": "assistant",
130
+ "content": text_parts,
131
+ "model": info.get("model"),
132
+ "time": info.get("time", {}),
133
+ }
134
+ structured = result.get("structured") or info.get("structured")
135
+ if structured is not None:
136
+ msg["structured"] = structured
137
+ return msg
138
+
139
+ raise RuntimeError(f"Tool loop exceeded {max_tool_rounds} rounds")
140
+
141
+ def messages(self, **kwargs) -> Any:
142
+ return self._client.v2_session_messages(self.id, **kwargs)
143
+
144
+ def context(self, **kwargs) -> List[SessionMessage]:
145
+ return self._client.v2_session_context(self.id, **kwargs)
146
+
147
+ def compact(self) -> Any:
148
+ return self._client.v2_session_compact(self.id)
149
+
150
+ def abort(self) -> Any:
151
+ return self._client.session_abort(self.id)
152
+
153
+ def fork(self, **kwargs) -> Any:
154
+ return self._client.session_fork(self.id, **kwargs)
155
+
156
+ def diff(self) -> Any:
157
+ return self._client.session_diff(self.id)
158
+
159
+ def todo(self) -> Any:
160
+ return self._client.session_todo(self.id)