opencode-python-sdk 0.1.3__py3-none-any.whl → 0.2.2__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 +19 -1
- opencode/_base_client.py +24 -3
- opencode/_client.py +173 -2
- opencode/_events.py +5 -2
- opencode/_rpc.py +9 -2
- opencode/_service.py +1018 -0
- 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/service.py +50 -0
- opencode/testing.py +508 -0
- {opencode_python_sdk-0.1.3.dist-info → opencode_python_sdk-0.2.2.dist-info}/METADATA +71 -1
- opencode_python_sdk-0.2.2.dist-info/RECORD +64 -0
- opencode_python_sdk-0.1.3.dist-info/RECORD +0 -58
- {opencode_python_sdk-0.1.3.dist-info → opencode_python_sdk-0.2.2.dist-info}/WHEEL +0 -0
- {opencode_python_sdk-0.1.3.dist-info → opencode_python_sdk-0.2.2.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.2
|
|
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/
|
|
@@ -350,6 +350,55 @@ client.session.get("ses_...", timeout=5.0) # per call
|
|
|
350
350
|
|
|
351
351
|
The default timeout is 10 minutes with a 10 second connect timeout.
|
|
352
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
|
+
|
|
353
402
|
## Running a server
|
|
354
403
|
|
|
355
404
|
`OpencodeServer` runs `opencode serve` as a child process on a free local port with a random password:
|
|
@@ -370,6 +419,27 @@ with server:
|
|
|
370
419
|
The server runs in `--stdio` mode, so it also exits when the Python process dies. `AsyncOpencodeServer` is the
|
|
371
420
|
async equivalent.
|
|
372
421
|
|
|
422
|
+
### The background service
|
|
423
|
+
|
|
424
|
+
OpenCode can also run one shared background server per user (`opencode serve --service`), which registers itself in
|
|
425
|
+
`$XDG_STATE_HOME/opencode/service.json`. `Opencode.from_service()` connects to it, starting it first if it is not
|
|
426
|
+
running:
|
|
427
|
+
|
|
428
|
+
```python
|
|
429
|
+
from opencode import Opencode, service
|
|
430
|
+
|
|
431
|
+
with Opencode.from_service(version="2.1.2") as client: # replaces a service running another version
|
|
432
|
+
print(client.server.info().version)
|
|
433
|
+
|
|
434
|
+
endpoint = service.discover() # an Endpoint, or None; never starts anything
|
|
435
|
+
service.stop(pty="handoff") # hand persistent terminals to the next service instead of closing them
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
`service.ensure()` returns the `Endpoint` (URL and credentials) without creating a client, and `service.headers()`
|
|
439
|
+
gives its `Authorization` header for other HTTP clients. Concurrent callers are safe: they race to start the service
|
|
440
|
+
and all get the one that won. `await AsyncOpencode.from_service()`, `service.async_discover()`,
|
|
441
|
+
`service.async_ensure()` and `service.async_stop()` are the async equivalents.
|
|
442
|
+
|
|
373
443
|
## Advanced
|
|
374
444
|
|
|
375
445
|
```python
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
opencode/.generated.json,sha256=QNCFazAYhcwsGf0l4A2EeSpm2HOM7TRBE648Zyoeq_s,1383
|
|
2
|
+
opencode/__init__.py,sha256=ZknWy2NBxHe4C6prS9gW56FBu2UoV-OLcMsMY4hnEKI,5759
|
|
3
|
+
opencode/_base_client.py,sha256=oA7Yah1WYyw16WrF_x76T1uZE5Arwt4jQMK8gyrL_4Y,21008
|
|
4
|
+
opencode/_client.py,sha256=nUkvwHuKRpISQgy4-d9c62nq9zMtzENsuqbQE4KKwAw,15402
|
|
5
|
+
opencode/_constants.py,sha256=-B_D8RHElL4mYN__WVf4bag4D12dJHbL69_EMfi7bXo,1132
|
|
6
|
+
opencode/_errors.py,sha256=dWjsyGV0aDBKsvJrGNKGJn41Yue66yBBwGTT2pTV7E8,16431
|
|
7
|
+
opencode/_events.py,sha256=Pg6zgPT37GHRITM5HvuxC6hmdZVrd2msyfsFQPoOAcQ,30179
|
|
8
|
+
opencode/_exceptions.py,sha256=kqQiaSM6zbfyiXXddwapIymW7J61ZlAa767Yko5Y9EI,8720
|
|
9
|
+
opencode/_models.py,sha256=2esvD2CmslOHCbuLNkN0sGDcgMAqeoMmB7hZMPzo1uA,1988
|
|
10
|
+
opencode/_pagination.py,sha256=uvB-0IwfkGqbYIYiUW_ax_oAE-ldZJSWHtjluVcZlNk,7052
|
|
11
|
+
opencode/_pty.py,sha256=wdqBF7K40ikzas8i203pem2iov8nbgYDC-RqH6ZX41E,23429
|
|
12
|
+
opencode/_qs.py,sha256=8EWbLzd-iLComngGRyO95f_z76j4tUeBihYJdHI5NP8,3643
|
|
13
|
+
opencode/_resource.py,sha256=ovuG85mWcuXNTu5m6H9FKPN2rrD5o52oRFAGBHimPyg,1263
|
|
14
|
+
opencode/_rpc.py,sha256=WrPQ_hRhsneXLvYj7WwEADC6_OCMRE5muuWVoysKPok,17708
|
|
15
|
+
opencode/_server.py,sha256=O-yVWU-v7HDJuVav1xXTil96BtvvbYMsHm1zLhneWKk,12849
|
|
16
|
+
opencode/_service.py,sha256=LtkGdZBbRH6I7F6PYnzMNNOqXudze_Uy3HO_ghnCPf4,36622
|
|
17
|
+
opencode/_streaming.py,sha256=TfnfZaNucjLrpfOB9m1i_sRtjYoACztTflavrhwvnDE,8861
|
|
18
|
+
opencode/_types.py,sha256=Z8bYxzpZhPRtisvPUv9R5g4oBlC23iXR70fFzReTOc0,2194
|
|
19
|
+
opencode/_unions.py,sha256=dW0dTmP2QK2lL6RhyK2YsjD2yNbO8Ah6pk-i5ySW3lo,1518
|
|
20
|
+
opencode/_version.py,sha256=zNErHu8z_g6ScOeYcO8QAiktg036FVPaUiIIZefeoLU,259
|
|
21
|
+
opencode/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
+
opencode/service.py,sha256=kFjQ4mjV30-VXx5B22ZPUs0V0e__fyTZ2Yj2VAoUrbU,921
|
|
23
|
+
opencode/testing.py,sha256=_EGoh9SwC5WCwbXjm5AtbyAzB4zyO5xX2N-skMLHzR4,21069
|
|
24
|
+
opencode/lib/__init__.py,sha256=mWBVu6RUOqMNJHHZPLq2nkks7QdsWJgLPN44BmfDv9s,120
|
|
25
|
+
opencode/lib/_ready.py,sha256=dhX-n6DDjRWsYJ95paxt1i5WnlU_KsFsTqgkJEt01Q4,3286
|
|
26
|
+
opencode/lib/_session.py,sha256=SCmBOYWdDyoCGKJ5YF2eQPxu8R0TmVhcV4C_xl1jFKA,9212
|
|
27
|
+
opencode/resources/__init__.py,sha256=wNPQqEDgNYedtWhub2wVWL85jEoE2qCQQHks5WHyAoM,13239
|
|
28
|
+
opencode/resources/agent.py,sha256=6vQC__e3cZxkJG-Xeqj66wh4kH1zOpGS9662N2dSa3s,5624
|
|
29
|
+
opencode/resources/command.py,sha256=60itt-z1mhUlJEHY7aUy6kUerIdostbhZf7y0I6EK74,3073
|
|
30
|
+
opencode/resources/config.py,sha256=L6FbrXeegplUsTjHQZX1KsAaGA0Yy0_w8MP8V5dRo7Q,7699
|
|
31
|
+
opencode/resources/credential.py,sha256=Z464vfgOSXMB-pi4LCCnUR4mWRtmFpefqlZ9rWQP_Fk,12084
|
|
32
|
+
opencode/resources/debug.py,sha256=sZvMb7WsiB65O-B3-U4DablrUWctOOvd6BWY_t6SW3w,5537
|
|
33
|
+
opencode/resources/event.py,sha256=zsaY6bmBt5zYCxxghJzWCyG342_2uAAP1xsVgyTaYwc,2763
|
|
34
|
+
opencode/resources/experimental.py,sha256=zfIBB_4TtVdFqBgksk5jmQ-9a_5hpPhPRT1f0vlPQoY,28172
|
|
35
|
+
opencode/resources/file.py,sha256=3J06QXIQT5_WLdPse3OLQqMN8ypCR6VsadndAQnHQEg,13013
|
|
36
|
+
opencode/resources/form.py,sha256=Lc4Ko_01ABZZTIQi8UOfmSO_xfhWP8UoAr5wBUNdAqY,3021
|
|
37
|
+
opencode/resources/generate.py,sha256=RFjg4dBXDhzhxGz5bO50igZZ5-W7bT6PIEw_ED45Su0,3809
|
|
38
|
+
opencode/resources/integration.py,sha256=q59coZDkdVcRk53q3wwffrySYAsPT_GMIjbirT7aAXY,37919
|
|
39
|
+
opencode/resources/location.py,sha256=Idd0K174jkzJvpEqoucSII1sP8ibuHZIfsz6X5id8_U,5357
|
|
40
|
+
opencode/resources/mcp.py,sha256=aC6jLoS18Ez_4nMRsUmxbMGSR2jGFMAPurRuoPKFL10,16401
|
|
41
|
+
opencode/resources/message.py,sha256=D5IF_mAD2GnFH8_Ggy7tEXECsm6Jx6dt3CO0haGPfic,5971
|
|
42
|
+
opencode/resources/migration.py,sha256=aqwIG6vcFZ1zzKwRR731ZOpLJeUMG7E9f1dIc8YOR4o,4252
|
|
43
|
+
opencode/resources/model.py,sha256=1SXCA7r6rukxUC6iar9I33Zs01gNGl92-J3p1LfRo38,5875
|
|
44
|
+
opencode/resources/permission.py,sha256=KjcppKC5VwYFB8RvrOO8-tAcERyQtMd-IvNQn-lOigY,24528
|
|
45
|
+
opencode/resources/plugin.py,sha256=_wa0UfT-_TGgE1lo3nkODXjyb26EXXB_WHgX8DTcGVQ,8221
|
|
46
|
+
opencode/resources/project.py,sha256=HfMDFqKbpzPqmmKg4WAxkM7_J7RfWr1QiWBKuZg-CPU,6184
|
|
47
|
+
opencode/resources/provider.py,sha256=f0z-nLqtGzvHSGIHj7LR7eVzLTT60D6Jw2aR7IkMtWs,6200
|
|
48
|
+
opencode/resources/pty.py,sha256=dReNx2ETWMhJHmZ53x4JhkkjXRWxVP_kfa5eQYlo3EU,17631
|
|
49
|
+
opencode/resources/reference.py,sha256=ovKtRm4vCMD0ulWJncXZIkuCQacBDQYKwOk5QOOHjMg,3143
|
|
50
|
+
opencode/resources/rpc.py,sha256=aBTeAvDyBK5NQeO-Yt9mZrqiOB4u4_8dQD082t4pO9s,3684
|
|
51
|
+
opencode/resources/server.py,sha256=Vr-uTexG20gtt53c23ny-iGy_BeXYCKJWAnu0P6Uauc,6998
|
|
52
|
+
opencode/resources/session.py,sha256=xQXN9_Af3OIlqEDQZPSCiByZkyn3ZXut9GD041Yk0ig,126883
|
|
53
|
+
opencode/resources/shell.py,sha256=-VzSkaVOaVM1GZdjM2rz9mAWffg9fnxy9KCseYzti-I,13911
|
|
54
|
+
opencode/resources/skill.py,sha256=R44XZaWeSyVZNbHHVUnLsqL81Mlg1cvqMkTYqHSRv_c,3025
|
|
55
|
+
opencode/resources/vcs.py,sha256=vZzdowfs4CbCMDDQ_uF48d5P4ym8qw4DZ3Q-_sjbDw8,17811
|
|
56
|
+
opencode/resources/voice.py,sha256=cM3eEBopD-DSfwPdiuwSzapeGZyvhroCA97WRrLXXT0,3988
|
|
57
|
+
opencode/resources/websearch.py,sha256=hqXcTPq-lGnqB34VMcFL59KbS2vvfwd-JntgUozKAw4,6271
|
|
58
|
+
opencode/resources/worktree.py,sha256=rRDysFG4zncjlIgjkwxT2UKkUgLXMr2DxeEfg5j2YrE,11016
|
|
59
|
+
opencode/types/__init__.py,sha256=5JEAe89RPSwJw1yz0kufBxtEJgRsMpZXj7KTbzGX45A,60935
|
|
60
|
+
opencode/types/_generated.py,sha256=c3zBAfVpZGJ9TX4FqrMZASmoxRntel_QzVuP85BOJwU,235015
|
|
61
|
+
opencode_python_sdk-0.2.2.dist-info/METADATA,sha256=d8EuoMpm4YOLAqDLpupbnPDrHbOJEZXln6pLYfELqrs,19023
|
|
62
|
+
opencode_python_sdk-0.2.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
63
|
+
opencode_python_sdk-0.2.2.dist-info/licenses/LICENSE,sha256=SbiOG1an_-gZ0gwGLS0tsbUnMHA7tYX22FgP1EqzrH4,1093
|
|
64
|
+
opencode_python_sdk-0.2.2.dist-info/RECORD,,
|