opencode-python-sdk 0.1.2__py3-none-any.whl → 0.2.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/__init__.py +62 -1
- opencode/_base_client.py +24 -3
- opencode/_client.py +90 -2
- opencode/_events.py +732 -0
- opencode/_rpc.py +489 -0
- opencode/_streaming.py +22 -1
- opencode/lib/__init__.py +5 -0
- opencode/lib/_ready.py +91 -0
- opencode/lib/_session.py +231 -0
- opencode/resources/agent.py +13 -11
- opencode/resources/command.py +9 -7
- opencode/resources/config.py +12 -10
- opencode/resources/credential.py +13 -11
- opencode/resources/debug.py +9 -7
- opencode/resources/event.py +9 -5
- opencode/resources/experimental.py +31 -29
- opencode/resources/file.py +15 -13
- opencode/resources/form.py +9 -7
- opencode/resources/generate.py +9 -7
- opencode/resources/integration.py +28 -26
- opencode/resources/location.py +9 -7
- opencode/resources/mcp.py +13 -11
- opencode/resources/message.py +9 -8
- opencode/resources/migration.py +10 -12
- opencode/resources/model.py +12 -10
- opencode/resources/permission.py +29 -27
- opencode/resources/plugin.py +12 -10
- opencode/resources/project.py +14 -12
- opencode/resources/provider.py +13 -11
- opencode/resources/pty.py +23 -21
- opencode/resources/reference.py +9 -7
- opencode/resources/rpc.py +9 -7
- opencode/resources/server.py +14 -12
- opencode/resources/session.py +92 -90
- opencode/resources/shell.py +19 -17
- opencode/resources/skill.py +9 -7
- opencode/resources/vcs.py +22 -20
- opencode/resources/voice.py +9 -7
- opencode/resources/websearch.py +13 -11
- opencode/resources/worktree.py +13 -11
- opencode/testing.py +508 -0
- {opencode_python_sdk-0.1.2.dist-info → opencode_python_sdk-0.2.0.dist-info}/METADATA +126 -1
- opencode_python_sdk-0.2.0.dist-info/RECORD +62 -0
- opencode_python_sdk-0.1.2.dist-info/RECORD +0 -56
- {opencode_python_sdk-0.1.2.dist-info → opencode_python_sdk-0.2.0.dist-info}/WHEEL +0 -0
- {opencode_python_sdk-0.1.2.dist-info → opencode_python_sdk-0.2.0.dist-info}/licenses/LICENSE +0 -0
opencode/testing.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""An in-process fake OpenCode server for your own tests.
|
|
2
|
+
|
|
3
|
+
:class:`FakeServer` is an ``httpx`` transport that answers like an OpenCode server, keeping sessions, messages and
|
|
4
|
+
events in memory. Plug it into a client and test code that uses the SDK without starting ``opencode serve`` or
|
|
5
|
+
calling a model::
|
|
6
|
+
|
|
7
|
+
from opencode import Opencode
|
|
8
|
+
from opencode.testing import FakeServer
|
|
9
|
+
|
|
10
|
+
fake = FakeServer(reply="Done.")
|
|
11
|
+
client = Opencode.fake(fake) # or Opencode(transport=fake)
|
|
12
|
+
|
|
13
|
+
result = client.session.run("Fix the bug", agent="build")
|
|
14
|
+
assert result.text == "Done."
|
|
15
|
+
assert fake.prompts == ["Fix the bug"]
|
|
16
|
+
|
|
17
|
+
It covers sessions (create, get, list, remove, switch agent or model), prompts, waiting, interrupts, messages,
|
|
18
|
+
agents, MCP servers, server info and the event stream. Every payload is built from the generated models, so it
|
|
19
|
+
has the shape the real server sends. Other routes answer ``404``. Use :meth:`FakeServer.fail` to make a route fail.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import itertools
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import time
|
|
29
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import httpx
|
|
34
|
+
|
|
35
|
+
from ._models import BaseModel
|
|
36
|
+
from .types import (
|
|
37
|
+
AgentInfo,
|
|
38
|
+
AgentListResponse,
|
|
39
|
+
LocationPublicRef,
|
|
40
|
+
McpListResponse,
|
|
41
|
+
McpServer,
|
|
42
|
+
ModelRef,
|
|
43
|
+
ServerConnectedEvent,
|
|
44
|
+
ServerInfo,
|
|
45
|
+
SessionCreatedEvent,
|
|
46
|
+
SessionIdleEvent,
|
|
47
|
+
SessionInboxUser,
|
|
48
|
+
SessionInfo,
|
|
49
|
+
SessionInterruptResponse,
|
|
50
|
+
SessionMessageAssistant,
|
|
51
|
+
SessionMessageAssistantText,
|
|
52
|
+
SessionMessageIdle,
|
|
53
|
+
SessionMessageUser,
|
|
54
|
+
TokenUsageInfo,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
__all__ = ["DEFAULT_MODEL", "FakeServer"]
|
|
58
|
+
|
|
59
|
+
DEFAULT_MODEL = ModelRef(id="fake-model", provider_id="fake")
|
|
60
|
+
"""Model reported for sessions created without one."""
|
|
61
|
+
|
|
62
|
+
Reply = str | Callable[[str], str]
|
|
63
|
+
"""Assistant reply: fixed text, or a function from the prompt text to the reply."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class _Failure:
|
|
68
|
+
method: str
|
|
69
|
+
path: re.Pattern[str]
|
|
70
|
+
status: int
|
|
71
|
+
body: object
|
|
72
|
+
exception: BaseException | None
|
|
73
|
+
times: int | None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FakeServer(httpx.MockTransport):
|
|
77
|
+
"""An in-memory OpenCode server, usable as the ``transport`` of :class:`opencode.Opencode` and
|
|
78
|
+
:class:`opencode.AsyncOpencode`.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
reply: What the assistant answers to each prompt: text, or a function of the prompt text.
|
|
82
|
+
agents: Agent ids :meth:`opencode.Opencode.agent.list` reports. Add more later with :meth:`add_agent`.
|
|
83
|
+
mcp_servers: MCP server names mapped to their status (``connected``, ``pending``, ``failed``, ...).
|
|
84
|
+
directory: Project directory reported as every session's location.
|
|
85
|
+
cost: Cost in USD reported for each assistant reply.
|
|
86
|
+
tokens: Input and output tokens reported for each assistant reply.
|
|
87
|
+
|
|
88
|
+
Attributes:
|
|
89
|
+
sessions: Sessions by id.
|
|
90
|
+
messages: Messages of each session, oldest first.
|
|
91
|
+
events: Events the stream replays after ``server.connected``.
|
|
92
|
+
requests: Every request received, in order.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
reply: Reply = "OK",
|
|
99
|
+
agents: Iterable[str] = ("build", "plan"),
|
|
100
|
+
mcp_servers: Mapping[str, str] | None = None,
|
|
101
|
+
directory: str = "/fake/project",
|
|
102
|
+
cost: float = 0.001,
|
|
103
|
+
tokens: tuple[int, int] = (10, 5),
|
|
104
|
+
) -> None:
|
|
105
|
+
super().__init__(self._handle)
|
|
106
|
+
self.reply: Reply = reply
|
|
107
|
+
self.directory = directory
|
|
108
|
+
self.cost = cost
|
|
109
|
+
self.tokens = tokens
|
|
110
|
+
self.sessions: dict[str, SessionInfo] = {}
|
|
111
|
+
self.messages: dict[str, list[BaseModel]] = {}
|
|
112
|
+
self.inbox: dict[str, list[SessionInboxUser]] = {}
|
|
113
|
+
self.events: list[BaseModel] = []
|
|
114
|
+
self.requests: list[httpx.Request] = []
|
|
115
|
+
self._agents: list[tuple[str, int]] = [(agent, 0) for agent in agents]
|
|
116
|
+
self._mcp: dict[str, tuple[str, int]] = {name: (status, 0) for name, status in (mcp_servers or {}).items()}
|
|
117
|
+
self._polls = {"agent": 0, "mcp": 0}
|
|
118
|
+
self._failures: list[_Failure] = []
|
|
119
|
+
self._ids = itertools.count(1)
|
|
120
|
+
|
|
121
|
+
# -- configuration --------------------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def add_agent(self, agent: str, *, after_polls: int = 0) -> None:
|
|
124
|
+
"""Lists ``agent`` once ``GET /api/agent`` has been called ``after_polls`` more times, like an agent that
|
|
125
|
+
loads after boot."""
|
|
126
|
+
self._agents.append((agent, self._polls["agent"] + after_polls))
|
|
127
|
+
|
|
128
|
+
def set_mcp_status(self, name: str, status: str = "connected", *, after_polls: int = 0) -> None:
|
|
129
|
+
"""Reports MCP server ``name`` with ``status`` once ``GET /api/mcp`` has been called ``after_polls`` more
|
|
130
|
+
times."""
|
|
131
|
+
self._mcp[name] = (status, self._polls["mcp"] + after_polls)
|
|
132
|
+
|
|
133
|
+
def fail(
|
|
134
|
+
self,
|
|
135
|
+
method: str,
|
|
136
|
+
path: str,
|
|
137
|
+
*,
|
|
138
|
+
status: int = 500,
|
|
139
|
+
body: object = None,
|
|
140
|
+
exception: BaseException | None = None,
|
|
141
|
+
times: int | None = 1,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Makes matching requests fail.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
method: HTTP method, such as ``POST``.
|
|
147
|
+
path: Regular expression matched against the whole request path, such as ``/api/session/[^/]+/prompt``.
|
|
148
|
+
status: Status of the error response.
|
|
149
|
+
body: JSON error body. Defaults to an untagged body, so the error class follows ``status``.
|
|
150
|
+
exception: Raise this from the transport instead of answering, for example ``httpx.ConnectError(...)``
|
|
151
|
+
or ``KeyboardInterrupt()``.
|
|
152
|
+
times: How many requests fail; ``None`` for all of them.
|
|
153
|
+
"""
|
|
154
|
+
if body is None:
|
|
155
|
+
body = {"message": f"Injected failure ({status})"}
|
|
156
|
+
self._failures.append(_Failure(method.upper(), re.compile(path), status, body, exception, times))
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def prompts(self) -> list[str]:
|
|
160
|
+
"""Text of every prompt received, in order."""
|
|
161
|
+
texts: list[str] = []
|
|
162
|
+
for request in self.requests:
|
|
163
|
+
if request.method == "POST" and request.url.path.endswith("/prompt"):
|
|
164
|
+
texts.append(json.loads(request.content)["text"])
|
|
165
|
+
return texts
|
|
166
|
+
|
|
167
|
+
# -- helpers --------------------------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def _id(self, prefix: str) -> str:
|
|
170
|
+
return f"{prefix}_{next(self._ids):026d}"
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _now() -> float:
|
|
174
|
+
return float(time.time_ns() // 1_000_000)
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def _location(self) -> LocationPublicRef:
|
|
178
|
+
return LocationPublicRef(directory=self.directory)
|
|
179
|
+
|
|
180
|
+
def _session(self, session_id: str) -> SessionInfo:
|
|
181
|
+
try:
|
|
182
|
+
return self.sessions[session_id]
|
|
183
|
+
except KeyError:
|
|
184
|
+
raise _ErrorResponse(
|
|
185
|
+
404,
|
|
186
|
+
{
|
|
187
|
+
"_tag": "SessionNotFoundError",
|
|
188
|
+
"sessionID": session_id,
|
|
189
|
+
"message": f"Session not found: {session_id}",
|
|
190
|
+
},
|
|
191
|
+
) from None
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _body(request: httpx.Request) -> dict[str, Any]:
|
|
195
|
+
if not request.content:
|
|
196
|
+
return {}
|
|
197
|
+
try:
|
|
198
|
+
payload = json.loads(request.content)
|
|
199
|
+
except ValueError:
|
|
200
|
+
raise _invalid("Request body is not JSON") from None
|
|
201
|
+
if not isinstance(payload, dict):
|
|
202
|
+
raise _invalid("Request body must be an object")
|
|
203
|
+
return dict(payload) # pyright: ignore[reportUnknownArgumentType]
|
|
204
|
+
|
|
205
|
+
def _record(self, event: BaseModel) -> None:
|
|
206
|
+
self.events.append(event)
|
|
207
|
+
|
|
208
|
+
def _run(self, session: SessionInfo, text: str) -> None:
|
|
209
|
+
"""Answers one prompt synchronously: assistant reply, idle marker, usage, events."""
|
|
210
|
+
reply = self.reply(text) if callable(self.reply) else self.reply
|
|
211
|
+
now = self._now()
|
|
212
|
+
input_tokens, output_tokens = self.tokens
|
|
213
|
+
usage = {"input": input_tokens, "output": output_tokens, "reasoning": 0, "cache": {"read": 0, "write": 0}}
|
|
214
|
+
self.messages[session.id] += [
|
|
215
|
+
SessionMessageAssistant.model_validate(
|
|
216
|
+
{
|
|
217
|
+
"id": self._id("msg"),
|
|
218
|
+
"type": "assistant",
|
|
219
|
+
"time": {"created": now, "completed": now},
|
|
220
|
+
"agent": session.agent or "build",
|
|
221
|
+
"model": (session.model or DEFAULT_MODEL).to_dict(),
|
|
222
|
+
"content": [SessionMessageAssistantText(type="text", text=reply).to_dict()],
|
|
223
|
+
"finish": "stop",
|
|
224
|
+
"cost": self.cost,
|
|
225
|
+
"tokens": usage,
|
|
226
|
+
}
|
|
227
|
+
),
|
|
228
|
+
SessionMessageIdle.model_validate(
|
|
229
|
+
{"id": self._id("msg"), "type": "idle", "time": {"created": now}, "outcome": "succeeded"}
|
|
230
|
+
),
|
|
231
|
+
]
|
|
232
|
+
session.cost += self.cost
|
|
233
|
+
session.tokens = TokenUsageInfo.model_validate(
|
|
234
|
+
{
|
|
235
|
+
"input": session.tokens.input + input_tokens,
|
|
236
|
+
"output": session.tokens.output + output_tokens,
|
|
237
|
+
"reasoning": session.tokens.reasoning,
|
|
238
|
+
"cache": session.tokens.cache.to_dict(),
|
|
239
|
+
}
|
|
240
|
+
)
|
|
241
|
+
session.outcome = "succeeded"
|
|
242
|
+
session.time.updated = session.time.idle = now
|
|
243
|
+
self._record(
|
|
244
|
+
SessionIdleEvent.model_validate(
|
|
245
|
+
{"id": self._id("evt"), "created": now, "type": "session.idle", "data": {"sessionID": session.id}}
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# -- routes ---------------------------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
def _server_info(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
|
|
252
|
+
info = ServerInfo.model_validate(
|
|
253
|
+
{
|
|
254
|
+
"version": "fake",
|
|
255
|
+
"pid": os.getpid(),
|
|
256
|
+
"urls": [str(request.url.copy_with(path="/"))],
|
|
257
|
+
"paths": {"tmp": "/tmp"},
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
return _json(info)
|
|
261
|
+
|
|
262
|
+
def _agent_list(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
|
|
263
|
+
self._polls["agent"] += 1
|
|
264
|
+
visible = [agent for agent, after in self._agents if self._polls["agent"] > after]
|
|
265
|
+
agents = [
|
|
266
|
+
AgentInfo.model_validate(
|
|
267
|
+
{
|
|
268
|
+
"id": agent,
|
|
269
|
+
"name": agent,
|
|
270
|
+
"mode": "primary",
|
|
271
|
+
"hidden": False,
|
|
272
|
+
"request": {"settings": {}, "headers": {}, "body": {}},
|
|
273
|
+
"permissions": [],
|
|
274
|
+
}
|
|
275
|
+
)
|
|
276
|
+
for agent in dict.fromkeys(visible)
|
|
277
|
+
]
|
|
278
|
+
return _json(AgentListResponse(location=self._location, data=agents))
|
|
279
|
+
|
|
280
|
+
def _mcp_list(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
|
|
281
|
+
self._polls["mcp"] += 1
|
|
282
|
+
servers = [
|
|
283
|
+
McpServer.model_validate(
|
|
284
|
+
{"name": name, "status": _mcp_status(status if self._polls["mcp"] > after else "pending")}
|
|
285
|
+
)
|
|
286
|
+
for name, (status, after) in self._mcp.items()
|
|
287
|
+
]
|
|
288
|
+
return _json(McpListResponse(location=self._location, data=servers))
|
|
289
|
+
|
|
290
|
+
def _session_create(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
|
|
291
|
+
body = self._body(request)
|
|
292
|
+
now = self._now()
|
|
293
|
+
session_id = body.get("id") or self._id("ses")
|
|
294
|
+
if session_id in self.sessions:
|
|
295
|
+
raise _ErrorResponse(409, {"_tag": "ConflictError", "message": f"Session already exists: {session_id}"})
|
|
296
|
+
session = SessionInfo.model_validate(
|
|
297
|
+
{
|
|
298
|
+
"id": session_id,
|
|
299
|
+
"parentID": body.get("parentID"),
|
|
300
|
+
"projectID": "prj_fake",
|
|
301
|
+
"agent": body.get("agent"),
|
|
302
|
+
"model": body.get("model") or DEFAULT_MODEL.to_dict(),
|
|
303
|
+
"cost": 0,
|
|
304
|
+
"tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}},
|
|
305
|
+
"time": {"created": now, "updated": now},
|
|
306
|
+
"title": body.get("title"),
|
|
307
|
+
"metadata": body.get("metadata"),
|
|
308
|
+
"location": self._location.to_dict(),
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
self.sessions[session.id] = session
|
|
312
|
+
self.messages[session.id] = []
|
|
313
|
+
self.inbox[session.id] = []
|
|
314
|
+
self._record(
|
|
315
|
+
SessionCreatedEvent.model_validate(
|
|
316
|
+
{
|
|
317
|
+
"id": self._id("evt"),
|
|
318
|
+
"created": now,
|
|
319
|
+
"type": "session.created",
|
|
320
|
+
"durable": {"aggregate_id": session.id, "seq": 0, "version": 1},
|
|
321
|
+
"data": {
|
|
322
|
+
"sessionID": session.id,
|
|
323
|
+
"projectID": session.project_id,
|
|
324
|
+
"location": {"directory": self.directory},
|
|
325
|
+
"slug": session.id,
|
|
326
|
+
"title": session.title,
|
|
327
|
+
"agent": session.agent,
|
|
328
|
+
"version": "fake",
|
|
329
|
+
},
|
|
330
|
+
}
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
return _json({"data": session.to_dict()})
|
|
334
|
+
|
|
335
|
+
def _session_list(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
|
|
336
|
+
sessions = list(self.sessions.values())
|
|
337
|
+
return _page(request, [session.to_dict() for session in sessions], newest_first_default=True)
|
|
338
|
+
|
|
339
|
+
def _session_get(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
|
|
340
|
+
return _json({"data": self._session(match["id"]).to_dict()})
|
|
341
|
+
|
|
342
|
+
def _session_remove(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
|
|
343
|
+
self._session(match["id"])
|
|
344
|
+
del self.sessions[match["id"]]
|
|
345
|
+
self.messages.pop(match["id"], None)
|
|
346
|
+
self.inbox.pop(match["id"], None)
|
|
347
|
+
return httpx.Response(204)
|
|
348
|
+
|
|
349
|
+
def _session_agent(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
|
|
350
|
+
session = self._session(match["id"])
|
|
351
|
+
agent = self._body(request).get("agent")
|
|
352
|
+
if not isinstance(agent, str):
|
|
353
|
+
raise _invalid("agent is required", field="agent")
|
|
354
|
+
session.agent = agent
|
|
355
|
+
return httpx.Response(204)
|
|
356
|
+
|
|
357
|
+
def _session_model(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
|
|
358
|
+
session = self._session(match["id"])
|
|
359
|
+
session.model = ModelRef.model_validate(self._body(request).get("model"))
|
|
360
|
+
return httpx.Response(204)
|
|
361
|
+
|
|
362
|
+
def _session_prompt(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
|
|
363
|
+
session = self._session(match["id"])
|
|
364
|
+
body = self._body(request)
|
|
365
|
+
text = body.get("text")
|
|
366
|
+
if not isinstance(text, str):
|
|
367
|
+
raise _invalid("text is required", field="text")
|
|
368
|
+
now = self._now()
|
|
369
|
+
message_id = body.get("id") or self._id("msg")
|
|
370
|
+
payload = {key: body[key] for key in ("text", "files", "agents", "skills", "metadata") if key in body}
|
|
371
|
+
queued = SessionInboxUser.model_validate(
|
|
372
|
+
{
|
|
373
|
+
"id": message_id,
|
|
374
|
+
"sessionID": session.id,
|
|
375
|
+
"time": {"created": now},
|
|
376
|
+
"type": "user",
|
|
377
|
+
"payload": payload,
|
|
378
|
+
"delivery": body.get("delivery") or "queue",
|
|
379
|
+
}
|
|
380
|
+
)
|
|
381
|
+
if body.get("resume") is False:
|
|
382
|
+
self.inbox[session.id].append(queued)
|
|
383
|
+
else:
|
|
384
|
+
self.messages[session.id].append(
|
|
385
|
+
SessionMessageUser.model_validate(
|
|
386
|
+
{
|
|
387
|
+
"id": message_id,
|
|
388
|
+
"type": "user",
|
|
389
|
+
"time": {"created": now},
|
|
390
|
+
"text": text,
|
|
391
|
+
"metadata": body.get("metadata"),
|
|
392
|
+
}
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
self._run(session, text)
|
|
396
|
+
return _json({"data": queued.to_dict()})
|
|
397
|
+
|
|
398
|
+
def _session_wait(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
|
|
399
|
+
self._session(match["id"])
|
|
400
|
+
return httpx.Response(204)
|
|
401
|
+
|
|
402
|
+
def _session_interrupt(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
|
|
403
|
+
self._session(match["id"])
|
|
404
|
+
return _json(SessionInterruptResponse(interrupted=False))
|
|
405
|
+
|
|
406
|
+
def _message_list(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
|
|
407
|
+
self._session(match["id"])
|
|
408
|
+
wanted = request.url.params.get("type")
|
|
409
|
+
items = [
|
|
410
|
+
m.to_dict() for m in self.messages[match["id"]] if wanted is None or getattr(m, "type", None) == wanted
|
|
411
|
+
]
|
|
412
|
+
return _page(request, items, newest_first_default=False)
|
|
413
|
+
|
|
414
|
+
def _event_stream(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
|
|
415
|
+
connected = ServerConnectedEvent.model_validate({"id": self._id("evt"), "type": "server.connected", "data": {}})
|
|
416
|
+
chunks = [f"data: {json.dumps(event.to_dict())}\n\n" for event in [connected, *self.events]]
|
|
417
|
+
return httpx.Response(200, headers={"content-type": "text/event-stream"}, content="".join(chunks).encode())
|
|
418
|
+
|
|
419
|
+
_ROUTES: tuple[tuple[str, str, Callable[..., httpx.Response]], ...] = (
|
|
420
|
+
("GET", r"/api/info", _server_info),
|
|
421
|
+
("GET", r"/api/agent", _agent_list),
|
|
422
|
+
("GET", r"/api/mcp", _mcp_list),
|
|
423
|
+
("GET", r"/api/event", _event_stream),
|
|
424
|
+
("POST", r"/api/session", _session_create),
|
|
425
|
+
("GET", r"/api/session", _session_list),
|
|
426
|
+
("GET", r"/api/session/(?P<id>[^/]+)", _session_get),
|
|
427
|
+
("DELETE", r"/api/session/(?P<id>[^/]+)", _session_remove),
|
|
428
|
+
("POST", r"/api/session/(?P<id>[^/]+)/agent", _session_agent),
|
|
429
|
+
("POST", r"/api/session/(?P<id>[^/]+)/model", _session_model),
|
|
430
|
+
("POST", r"/api/session/(?P<id>[^/]+)/prompt", _session_prompt),
|
|
431
|
+
("POST", r"/api/session/(?P<id>[^/]+)/interrupt", _session_interrupt),
|
|
432
|
+
("GET", r"/api/session/(?P<id>[^/]+)/message", _message_list),
|
|
433
|
+
("POST", r"/api/experimental/session/(?P<id>[^/]+)/wait", _session_wait),
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
def _handle(self, request: httpx.Request) -> httpx.Response:
|
|
437
|
+
self.requests.append(request)
|
|
438
|
+
path = request.url.path
|
|
439
|
+
for failure in self._failures:
|
|
440
|
+
if failure.method == request.method and failure.path.fullmatch(path) and failure.times != 0:
|
|
441
|
+
if failure.times is not None:
|
|
442
|
+
failure.times -= 1
|
|
443
|
+
if failure.exception is not None:
|
|
444
|
+
raise failure.exception
|
|
445
|
+
return httpx.Response(failure.status, json=failure.body)
|
|
446
|
+
for method, pattern, handler in self._ROUTES:
|
|
447
|
+
match = re.fullmatch(pattern, path)
|
|
448
|
+
if match and method == request.method:
|
|
449
|
+
try:
|
|
450
|
+
return handler(self, request, match)
|
|
451
|
+
except _ErrorResponse as error:
|
|
452
|
+
return httpx.Response(error.status, json=error.body)
|
|
453
|
+
return httpx.Response(
|
|
454
|
+
404, json={"_tag": "NotFoundError", "message": f"FakeServer does not implement {request.method} {path}"}
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
class _ErrorResponse(Exception):
|
|
459
|
+
def __init__(self, status: int, body: dict[str, object]) -> None:
|
|
460
|
+
super().__init__(body.get("message"))
|
|
461
|
+
self.status = status
|
|
462
|
+
self.body = body
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _mcp_status(status: str) -> dict[str, object]:
|
|
466
|
+
if status == "failed":
|
|
467
|
+
return {"status": status, "error": "Fake MCP server failed to start"}
|
|
468
|
+
return {"status": status}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _invalid(message: str, *, field: str | None = None) -> _ErrorResponse:
|
|
472
|
+
return _ErrorResponse(400, {"_tag": "InvalidRequestError", "message": message, "field": field})
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _json(payload: BaseModel | dict[str, Any]) -> httpx.Response:
|
|
476
|
+
return httpx.Response(200, json=payload.to_dict() if isinstance(payload, BaseModel) else payload)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _page(request: httpx.Request, items: list[dict[str, Any]], *, newest_first_default: bool) -> httpx.Response:
|
|
480
|
+
"""Cursor pagination over ``items`` (oldest first). Cursors encode the order and offset, like the server's."""
|
|
481
|
+
params = request.url.params
|
|
482
|
+
cursor = params.get("cursor")
|
|
483
|
+
if cursor is not None:
|
|
484
|
+
if "order" in params:
|
|
485
|
+
return httpx.Response(
|
|
486
|
+
400, json={"_tag": "InvalidRequestError", "message": "order cannot be combined with cursor"}
|
|
487
|
+
)
|
|
488
|
+
order, _, offset_text = cursor.partition(":")
|
|
489
|
+
if order not in ("asc", "desc") or not offset_text.isdigit():
|
|
490
|
+
return httpx.Response(400, json={"_tag": "InvalidCursorError", "message": f"Invalid cursor: {cursor}"})
|
|
491
|
+
offset = int(offset_text)
|
|
492
|
+
else:
|
|
493
|
+
order = params.get("order") or ("desc" if newest_first_default else "asc")
|
|
494
|
+
offset = 0
|
|
495
|
+
limit = int(params.get("limit") or 50)
|
|
496
|
+
ordered = list(reversed(items)) if order == "desc" else items
|
|
497
|
+
data = ordered[offset : offset + limit]
|
|
498
|
+
end = offset + len(data)
|
|
499
|
+
return httpx.Response(
|
|
500
|
+
200,
|
|
501
|
+
json={
|
|
502
|
+
"data": data,
|
|
503
|
+
"cursor": {
|
|
504
|
+
"previous": f"{order}:{max(offset - limit, 0)}" if offset > 0 else None,
|
|
505
|
+
"next": f"{order}:{end}" if end < len(ordered) else None,
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: opencode-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Typed Python client for the OpenCode 2.x server API
|
|
5
5
|
Project-URL: Homepage, https://github.com/shrijayan/opencode-python-sdk
|
|
6
6
|
Project-URL: Documentation, https://shrijayan.github.io/opencode-python-sdk/
|
|
@@ -206,6 +206,82 @@ Events are typed (`SessionCreatedEvent`, `SessionTextDeltaEvent`, and so on). Ev
|
|
|
206
206
|
not know arrive as `UnknownEvent` with all their fields kept. Streams apply only the connect timeout by default, since
|
|
207
207
|
they stay open between events.
|
|
208
208
|
|
|
209
|
+
### Shared event stream
|
|
210
|
+
|
|
211
|
+
`client.events` shares one connection among every subscriber, like `client.event` in the TypeScript client. The
|
|
212
|
+
connection opens with the first subscriber and closes when the last one leaves. Each subscriber has its own queue of
|
|
213
|
+
up to 4096 events; one that falls further behind fails with `EventOverflowError` without affecting the others. The
|
|
214
|
+
latest `server.connected` event is replayed to subscribers that join later.
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from opencode.types import SessionIdleEvent
|
|
218
|
+
|
|
219
|
+
with client.events.of_type(SessionIdleEvent).for_session(session.id) as idle:
|
|
220
|
+
client.session.prompt(session.id, text="Hello")
|
|
221
|
+
event = idle.get(timeout=300) # typed as SessionIdleEvent
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
- Filters chain: `of_type(*classes)` narrows the yielded type, `for_session(id)` keeps one session's events, and
|
|
225
|
+
`where(predicate)` takes any test. Entering the `with` block joins the stream, so nothing published after that is
|
|
226
|
+
missed.
|
|
227
|
+
- `subscribe(on_activity=callback)` calls `callback` for every chunk received, keepalive comments included, as a
|
|
228
|
+
liveness signal.
|
|
229
|
+
- `close()` (or leaving the `with` block) ends the subscription, also from another thread. In async code, cancel a wait
|
|
230
|
+
with `anyio.fail_after`, `asyncio.timeout` or a trio cancel scope; the subscription stays usable.
|
|
231
|
+
- Reconnection is opt-in: `EventHub(client, reconnect=True)` (or `AsyncEventHub`, or a `Reconnect(...)` policy) reopens
|
|
232
|
+
the connection with exponential backoff after connection errors, 5xx and 429. Subscribers see `server.connected`
|
|
233
|
+
once. **The stream is volatile**: events published while disconnected are lost, so re-read state through the API
|
|
234
|
+
after a reconnect when it matters.
|
|
235
|
+
|
|
236
|
+
The async hub works the same way under asyncio and trio:
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
async with client.events.for_session(session.id) as events:
|
|
240
|
+
async for event in events:
|
|
241
|
+
print(event.type)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`client.event.subscribe()` still opens a dedicated connection.
|
|
245
|
+
|
|
246
|
+
### Plugin RPC
|
|
247
|
+
|
|
248
|
+
Declare a plugin RPC once as a class, then call it with typed inputs and outputs:
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
from pydantic import BaseModel
|
|
252
|
+
from opencode import RpcCallError, RpcDefinition, rpc_event, rpc_method
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class AddInput(BaseModel):
|
|
256
|
+
amount: int
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class Total(BaseModel):
|
|
260
|
+
total: int
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class Counter(RpcDefinition, id="counter"):
|
|
264
|
+
add = rpc_method(AddInput, Total)
|
|
265
|
+
reset = rpc_method(None, Total)
|
|
266
|
+
changed = rpc_event(Total)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
counter = client.rpc(Counter)
|
|
270
|
+
try:
|
|
271
|
+
print(counter.call(Counter.add, AddInput(amount=2)).total)
|
|
272
|
+
except RpcCallError as error:
|
|
273
|
+
print(error.type, error.message, error.data)
|
|
274
|
+
|
|
275
|
+
with counter.subscribe(Counter.changed) as changes: # only rpc.counter.changed, with validated data
|
|
276
|
+
for change in changes:
|
|
277
|
+
print(change.data.total)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Any type pydantic validates works for inputs, outputs and event data. `rpc_method(..., errors={"not_found": Model})`
|
|
281
|
+
validates the `data` of declared errors. Failures raise `RpcCallError`; crashes and outputs that do not match the
|
|
282
|
+
declared type raise `RpcCallInternalError`. `counter.events()` yields every raw event of the RPC. The untyped
|
|
283
|
+
`client.rpc.call(rpc_id, method, input=...)` is still available.
|
|
284
|
+
|
|
209
285
|
## Terminals
|
|
210
286
|
|
|
211
287
|
`client.pty.attach()` opens an interactive connection to a PTY over a WebSocket. It needs the `pty` extra:
|
|
@@ -274,6 +350,55 @@ client.session.get("ses_...", timeout=5.0) # per call
|
|
|
274
350
|
|
|
275
351
|
The default timeout is 10 minutes with a 10 second connect timeout.
|
|
276
352
|
|
|
353
|
+
## Running an agent headless
|
|
354
|
+
|
|
355
|
+
`client.session.run` does the usual "prompt an agent and wait" round trip in one call: it creates a session (or
|
|
356
|
+
reuses `session_id`), sends the prompt, blocks until the session is idle, and returns the new messages with the
|
|
357
|
+
final text and usage. `client.wait_until_ready` waits until agents and MCP servers have loaded, which happens
|
|
358
|
+
asynchronously after the server boots. Both have async versions on `AsyncOpencode`.
|
|
359
|
+
|
|
360
|
+
```python
|
|
361
|
+
from opencode import Opencode
|
|
362
|
+
from opencode.types import ModelRef, PromptInputFileAttachment
|
|
363
|
+
|
|
364
|
+
with Opencode("http://127.0.0.1:4096", password="...") as client:
|
|
365
|
+
client.wait_until_ready(agents=["reviewer"], mcp_servers=["github"], timeout=60)
|
|
366
|
+
result = client.session.run(
|
|
367
|
+
"Review the attached diff",
|
|
368
|
+
agent="reviewer",
|
|
369
|
+
model=ModelRef(provider_id="anthropic", id="claude-sonnet-5-5"), # set it with the agent
|
|
370
|
+
files=[PromptInputFileAttachment(uri="data:text/plain;base64,...", name="diff.patch")],
|
|
371
|
+
timeout=600,
|
|
372
|
+
)
|
|
373
|
+
print(result.outcome, result.text)
|
|
374
|
+
print(result.cost, result.tokens.input, result.tokens.output) # this run; result.session has the totals
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
If the wait ends early (Ctrl-C, task cancellation, `timeout`, a connection error), `run` interrupts the session
|
|
378
|
+
on the server before re-raising, so the agent does not keep running unattended.
|
|
379
|
+
|
|
380
|
+
## Testing your code
|
|
381
|
+
|
|
382
|
+
`opencode.testing.FakeServer` is an in-process fake of the server: an `httpx` transport that keeps sessions,
|
|
383
|
+
messages and events in memory and answers with the same payloads as the real server. No process, no model.
|
|
384
|
+
|
|
385
|
+
```python
|
|
386
|
+
from opencode import Opencode
|
|
387
|
+
from opencode.testing import FakeServer
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def test_summarize() -> None:
|
|
391
|
+
fake = FakeServer(reply=lambda prompt: f"summary of {prompt!r}")
|
|
392
|
+
client = Opencode.fake(fake) # same as Opencode(transport=fake)
|
|
393
|
+
|
|
394
|
+
assert client.session.run("README").text == "summary of 'README'"
|
|
395
|
+
assert fake.prompts == ["README"]
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
`fake.fail(method, path_regex, status=..., exception=...)` injects errors, `fake.add_agent(..., after_polls=n)`
|
|
399
|
+
and `fake.set_mcp_status(...)` simulate slow boots, and `fake.requests` records every request. Routes the fake
|
|
400
|
+
does not implement answer `404`.
|
|
401
|
+
|
|
277
402
|
## Running a server
|
|
278
403
|
|
|
279
404
|
`OpencodeServer` runs `opencode serve` as a child process on a free local port with a random password:
|