opencode-python-sdk 0.1.3__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 +14 -1
- opencode/_base_client.py +24 -3
- opencode/_client.py +67 -2
- opencode/_events.py +5 -2
- opencode/_rpc.py +9 -2
- 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.3.dist-info → opencode_python_sdk-0.2.0.dist-info}/METADATA +50 -1
- opencode_python_sdk-0.2.0.dist-info/RECORD +62 -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.0.dist-info}/WHEEL +0 -0
- {opencode_python_sdk-0.1.3.dist-info → opencode_python_sdk-0.2.0.dist-info}/licenses/LICENSE +0 -0
opencode/__init__.py
CHANGED
|
@@ -14,7 +14,8 @@ Quick start::
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
-
from
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
18
19
|
from ._client import AsyncOpencode, Opencode
|
|
19
20
|
|
|
20
21
|
# <generated:error-imports>
|
|
@@ -108,6 +109,9 @@ from ._rpc import (
|
|
|
108
109
|
rpc_method,
|
|
109
110
|
)
|
|
110
111
|
|
|
112
|
+
if TYPE_CHECKING:
|
|
113
|
+
from . import types
|
|
114
|
+
|
|
111
115
|
# <generated:api-version>
|
|
112
116
|
API_VERSION = "2.1.2"
|
|
113
117
|
"""The OpenCode server version this client was generated from."""
|
|
@@ -206,3 +210,12 @@ __all__ = [
|
|
|
206
210
|
"WorktreeError",
|
|
207
211
|
# </generated:error-exports>
|
|
208
212
|
]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def __getattr__(name: str) -> Any:
|
|
216
|
+
# `opencode.types` builds several hundred models, so it is imported on first use rather than with the package.
|
|
217
|
+
if name == "types":
|
|
218
|
+
import importlib
|
|
219
|
+
|
|
220
|
+
return importlib.import_module(".types", __name__)
|
|
221
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
opencode/_base_client.py
CHANGED
|
@@ -7,6 +7,7 @@ transient failures, and turns responses into typed values or errors.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import base64
|
|
10
|
+
import builtins
|
|
10
11
|
import email.utils
|
|
11
12
|
import functools
|
|
12
13
|
import json
|
|
@@ -60,8 +61,18 @@ def _cached_adapter(tp: Any) -> pydantic.TypeAdapter[Any]:
|
|
|
60
61
|
return pydantic.TypeAdapter(tp)
|
|
61
62
|
|
|
62
63
|
|
|
64
|
+
@functools.cache
|
|
65
|
+
def _resolve_type(expr: str) -> Any:
|
|
66
|
+
# Generated resources name their response types as strings, so `import opencode` does not build every model.
|
|
67
|
+
from . import types
|
|
68
|
+
|
|
69
|
+
return eval(expr, {**vars(types), "builtins": builtins}) # expressions come from the generator
|
|
70
|
+
|
|
71
|
+
|
|
63
72
|
def type_adapter(tp: Any) -> pydantic.TypeAdapter[Any]:
|
|
64
|
-
"""Returns a cached ``TypeAdapter`` for a response type."""
|
|
73
|
+
"""Returns a cached ``TypeAdapter`` for a response type, or for a type expression over ``opencode.types``."""
|
|
74
|
+
if isinstance(tp, str):
|
|
75
|
+
tp = _resolve_type(tp)
|
|
65
76
|
try:
|
|
66
77
|
return _cached_adapter(tp)
|
|
67
78
|
except TypeError: # unhashable type expression
|
|
@@ -307,6 +318,7 @@ class SyncAPIClient(_BaseClient):
|
|
|
307
318
|
default_headers: Mapping[str, str] | None,
|
|
308
319
|
default_query: Mapping[str, object] | None,
|
|
309
320
|
http_client: httpx.Client | None,
|
|
321
|
+
transport: httpx.BaseTransport | None = None,
|
|
310
322
|
) -> None:
|
|
311
323
|
super().__init__(
|
|
312
324
|
base_url=base_url,
|
|
@@ -319,7 +331,11 @@ class SyncAPIClient(_BaseClient):
|
|
|
319
331
|
default_query=default_query,
|
|
320
332
|
)
|
|
321
333
|
self._owns_http = http_client is None
|
|
322
|
-
|
|
334
|
+
if http_client is not None and transport is not None:
|
|
335
|
+
raise ValueError("Pass either http_client or transport, not both")
|
|
336
|
+
self._http = http_client or httpx.Client(
|
|
337
|
+
limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True, transport=transport
|
|
338
|
+
)
|
|
323
339
|
|
|
324
340
|
def close(self) -> None:
|
|
325
341
|
"""Closes the connection pool, unless it was passed in by the caller."""
|
|
@@ -421,6 +437,7 @@ class AsyncAPIClient(_BaseClient):
|
|
|
421
437
|
default_headers: Mapping[str, str] | None,
|
|
422
438
|
default_query: Mapping[str, object] | None,
|
|
423
439
|
http_client: httpx.AsyncClient | None,
|
|
440
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
424
441
|
) -> None:
|
|
425
442
|
super().__init__(
|
|
426
443
|
base_url=base_url,
|
|
@@ -433,7 +450,11 @@ class AsyncAPIClient(_BaseClient):
|
|
|
433
450
|
default_query=default_query,
|
|
434
451
|
)
|
|
435
452
|
self._owns_http = http_client is None
|
|
436
|
-
|
|
453
|
+
if http_client is not None and transport is not None:
|
|
454
|
+
raise ValueError("Pass either http_client or transport, not both")
|
|
455
|
+
self._http = http_client or httpx.AsyncClient(
|
|
456
|
+
limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True, transport=transport
|
|
457
|
+
)
|
|
437
458
|
|
|
438
459
|
async def close(self) -> None:
|
|
439
460
|
"""Closes the connection pool, unless it was passed in by the caller."""
|
opencode/_client.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from functools import cached_property
|
|
4
|
-
from typing import TYPE_CHECKING
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
5
|
|
|
6
6
|
from typing_extensions import Self
|
|
7
7
|
|
|
@@ -14,12 +14,17 @@ from .resources import AsyncResources, SyncResources
|
|
|
14
14
|
|
|
15
15
|
if TYPE_CHECKING:
|
|
16
16
|
import os
|
|
17
|
-
from collections.abc import Mapping
|
|
17
|
+
from collections.abc import Iterable, Mapping
|
|
18
18
|
|
|
19
19
|
import httpx
|
|
20
20
|
|
|
21
|
+
from .testing import FakeServer
|
|
22
|
+
|
|
21
23
|
__all__ = ["AsyncOpencode", "Opencode"]
|
|
22
24
|
|
|
25
|
+
FAKE_BASE_URL = "http://opencode.fake"
|
|
26
|
+
"""Base URL of clients made by ``Opencode.fake()``."""
|
|
27
|
+
|
|
23
28
|
|
|
24
29
|
class Opencode(SyncAPIClient, SyncResources):
|
|
25
30
|
"""Synchronous client for an OpenCode server.
|
|
@@ -38,6 +43,8 @@ class Opencode(SyncAPIClient, SyncResources):
|
|
|
38
43
|
default_query: Query parameters sent with every request.
|
|
39
44
|
http_client: A preconfigured ``httpx.Client`` (proxies, custom transports, event hooks). The caller keeps
|
|
40
45
|
ownership and must close it.
|
|
46
|
+
transport: An ``httpx`` transport for the client's own connection pool, such as
|
|
47
|
+
:class:`opencode.testing.FakeServer`. Cannot be combined with ``http_client``.
|
|
41
48
|
|
|
42
49
|
Example::
|
|
43
50
|
|
|
@@ -60,6 +67,7 @@ class Opencode(SyncAPIClient, SyncResources):
|
|
|
60
67
|
default_headers: Mapping[str, str] | None = None,
|
|
61
68
|
default_query: Mapping[str, object] | None = None,
|
|
62
69
|
http_client: httpx.Client | None = None,
|
|
70
|
+
transport: httpx.BaseTransport | None = None,
|
|
63
71
|
) -> None:
|
|
64
72
|
super().__init__(
|
|
65
73
|
base_url=base_url,
|
|
@@ -71,6 +79,7 @@ class Opencode(SyncAPIClient, SyncResources):
|
|
|
71
79
|
default_headers=default_headers,
|
|
72
80
|
default_query=default_query,
|
|
73
81
|
http_client=http_client,
|
|
82
|
+
transport=transport,
|
|
74
83
|
)
|
|
75
84
|
|
|
76
85
|
@cached_property
|
|
@@ -83,6 +92,33 @@ class Opencode(SyncAPIClient, SyncResources):
|
|
|
83
92
|
"""Plugin RPC calls: ``client.rpc.call(...)``, or ``client.rpc(Definition)`` for typed access."""
|
|
84
93
|
return TypedRpcResource(self)
|
|
85
94
|
|
|
95
|
+
@classmethod
|
|
96
|
+
def fake(cls, server: FakeServer | None = None, **kwargs: Any) -> Self:
|
|
97
|
+
"""Returns a client wired to an in-process :class:`opencode.testing.FakeServer`, for tests.
|
|
98
|
+
|
|
99
|
+
Pass your own ``server`` to configure replies and inspect requests; other arguments go to the constructor.
|
|
100
|
+
"""
|
|
101
|
+
from .testing import FakeServer
|
|
102
|
+
|
|
103
|
+
kwargs.setdefault("password", "")
|
|
104
|
+
return cls(FAKE_BASE_URL, transport=server if server is not None else FakeServer(), **kwargs)
|
|
105
|
+
|
|
106
|
+
def wait_until_ready(
|
|
107
|
+
self, *, agents: Iterable[str] = (), mcp_servers: Iterable[str] = (), timeout: float = 60
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Blocks until the server answers, the named ``agents`` are listed and the named ``mcp_servers`` are
|
|
110
|
+
connected.
|
|
111
|
+
|
|
112
|
+
Agents load asynchronously after the server starts, so call this before creating sessions on a freshly
|
|
113
|
+
booted server. Connection errors and 503s while the server boots count as not ready yet.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
TimeoutError: Something was still missing after ``timeout`` seconds. The message names it.
|
|
117
|
+
"""
|
|
118
|
+
from .lib._ready import wait_until_ready
|
|
119
|
+
|
|
120
|
+
wait_until_ready(self, agents=agents, mcp_servers=mcp_servers, timeout=timeout)
|
|
121
|
+
|
|
86
122
|
def with_options(
|
|
87
123
|
self,
|
|
88
124
|
*,
|
|
@@ -138,6 +174,7 @@ class AsyncOpencode(AsyncAPIClient, AsyncResources):
|
|
|
138
174
|
default_headers: Mapping[str, str] | None = None,
|
|
139
175
|
default_query: Mapping[str, object] | None = None,
|
|
140
176
|
http_client: httpx.AsyncClient | None = None,
|
|
177
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
141
178
|
) -> None:
|
|
142
179
|
super().__init__(
|
|
143
180
|
base_url=base_url,
|
|
@@ -149,6 +186,7 @@ class AsyncOpencode(AsyncAPIClient, AsyncResources):
|
|
|
149
186
|
default_headers=default_headers,
|
|
150
187
|
default_query=default_query,
|
|
151
188
|
http_client=http_client,
|
|
189
|
+
transport=transport,
|
|
152
190
|
)
|
|
153
191
|
|
|
154
192
|
@cached_property
|
|
@@ -161,6 +199,33 @@ class AsyncOpencode(AsyncAPIClient, AsyncResources):
|
|
|
161
199
|
"""Plugin RPC calls: ``client.rpc.call(...)``, or ``client.rpc(Definition)`` for typed access."""
|
|
162
200
|
return AsyncTypedRpcResource(self)
|
|
163
201
|
|
|
202
|
+
@classmethod
|
|
203
|
+
def fake(cls, server: FakeServer | None = None, **kwargs: Any) -> Self:
|
|
204
|
+
"""Returns a client wired to an in-process :class:`opencode.testing.FakeServer`, for tests.
|
|
205
|
+
|
|
206
|
+
Pass your own ``server`` to configure replies and inspect requests; other arguments go to the constructor.
|
|
207
|
+
"""
|
|
208
|
+
from .testing import FakeServer
|
|
209
|
+
|
|
210
|
+
kwargs.setdefault("password", "")
|
|
211
|
+
return cls(FAKE_BASE_URL, transport=server if server is not None else FakeServer(), **kwargs)
|
|
212
|
+
|
|
213
|
+
async def wait_until_ready(
|
|
214
|
+
self, *, agents: Iterable[str] = (), mcp_servers: Iterable[str] = (), timeout: float = 60
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Blocks until the server answers, the named ``agents`` are listed and the named ``mcp_servers`` are
|
|
217
|
+
connected.
|
|
218
|
+
|
|
219
|
+
Agents load asynchronously after the server starts, so call this before creating sessions on a freshly
|
|
220
|
+
booted server. Connection errors and 503s while the server boots count as not ready yet.
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
TimeoutError: Something was still missing after ``timeout`` seconds. The message names it.
|
|
224
|
+
"""
|
|
225
|
+
from .lib._ready import async_wait_until_ready
|
|
226
|
+
|
|
227
|
+
await async_wait_until_ready(self, agents=agents, mcp_servers=mcp_servers, timeout=timeout)
|
|
228
|
+
|
|
164
229
|
def with_options(
|
|
165
230
|
self,
|
|
166
231
|
*,
|
opencode/_events.py
CHANGED
|
@@ -31,7 +31,6 @@ import anyio
|
|
|
31
31
|
from typing_extensions import Self
|
|
32
32
|
|
|
33
33
|
from ._exceptions import APIConnectionError, APIStatusError, OpencodeError
|
|
34
|
-
from .types import ServerConnectedEvent
|
|
35
34
|
|
|
36
35
|
if TYPE_CHECKING:
|
|
37
36
|
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
|
@@ -39,7 +38,7 @@ if TYPE_CHECKING:
|
|
|
39
38
|
|
|
40
39
|
from ._client import AsyncOpencode, Opencode
|
|
41
40
|
from ._streaming import AsyncStream, Stream
|
|
42
|
-
from .types import Event
|
|
41
|
+
from .types import Event, ServerConnectedEvent
|
|
43
42
|
|
|
44
43
|
__all__ = [
|
|
45
44
|
"SUBSCRIBER_CAPACITY",
|
|
@@ -302,6 +301,8 @@ class EventHub:
|
|
|
302
301
|
|
|
303
302
|
def _read(self, connection: _SyncConnection) -> None:
|
|
304
303
|
"""Reads one connection until it ends, setting ``connection.delivered`` once an event arrives."""
|
|
304
|
+
from .types import ServerConnectedEvent
|
|
305
|
+
|
|
305
306
|
stream = self._client.event.subscribe()
|
|
306
307
|
with self._lock:
|
|
307
308
|
connection.stream = stream
|
|
@@ -605,6 +606,8 @@ class AsyncEventHub:
|
|
|
605
606
|
|
|
606
607
|
async def _read(self, connection: _AsyncConnection) -> None:
|
|
607
608
|
"""Reads one connection until it ends, setting ``connection.delivered`` once an event arrives."""
|
|
609
|
+
from .types import ServerConnectedEvent
|
|
610
|
+
|
|
608
611
|
stream: AsyncStream[Event] = await self._client.event.subscribe()
|
|
609
612
|
try:
|
|
610
613
|
stream.on_activity = connection.activity
|
opencode/_rpc.py
CHANGED
|
@@ -44,7 +44,6 @@ from ._errors import RpcError, RpcInternalError
|
|
|
44
44
|
from ._exceptions import OpencodeError, validation_summary
|
|
45
45
|
from ._types import NOT_GIVEN, NotGiven
|
|
46
46
|
from .resources.rpc import AsyncRpcResource, RpcResource
|
|
47
|
-
from .types import RpcEvent
|
|
48
47
|
|
|
49
48
|
if TYPE_CHECKING:
|
|
50
49
|
from collections.abc import Mapping
|
|
@@ -53,7 +52,7 @@ if TYPE_CHECKING:
|
|
|
53
52
|
from ._client import AsyncOpencode, Opencode
|
|
54
53
|
from ._events import AsyncSubscription, Subscription
|
|
55
54
|
from ._types import Headers, Timeout
|
|
56
|
-
from .types import Location
|
|
55
|
+
from .types import Location, RpcEvent
|
|
57
56
|
|
|
58
57
|
__all__ = [
|
|
59
58
|
"AsyncRpcClient",
|
|
@@ -327,11 +326,15 @@ class RpcClient(Generic[_D]):
|
|
|
327
326
|
|
|
328
327
|
def events(self) -> Subscription[RpcEvent]:
|
|
329
328
|
"""Subscribes to every event of this RPC, as raw events, on the client's shared event stream."""
|
|
329
|
+
from .types import RpcEvent
|
|
330
|
+
|
|
330
331
|
prefix = f"rpc.{self.definition.id}."
|
|
331
332
|
return self._client.events.of_type(RpcEvent).where(lambda event: event.type.startswith(prefix))
|
|
332
333
|
|
|
333
334
|
def subscribe(self, event: RpcEventDefinition[_T]) -> RpcEvents[_T]:
|
|
334
335
|
"""Subscribes to one event of this RPC, with validated data, on the client's shared event stream."""
|
|
336
|
+
from .types import RpcEvent
|
|
337
|
+
|
|
335
338
|
_check_event(self.definition, event)
|
|
336
339
|
wire = self.definition.event_type(event)
|
|
337
340
|
return RpcEvents(self._client.events.of_type(RpcEvent).where(lambda raw: raw.type == wire), event)
|
|
@@ -396,11 +399,15 @@ class AsyncRpcClient(Generic[_D]):
|
|
|
396
399
|
|
|
397
400
|
def events(self) -> AsyncSubscription[RpcEvent]:
|
|
398
401
|
"""Subscribes to every event of this RPC, as raw events, on the client's shared event stream."""
|
|
402
|
+
from .types import RpcEvent
|
|
403
|
+
|
|
399
404
|
prefix = f"rpc.{self.definition.id}."
|
|
400
405
|
return self._client.events.of_type(RpcEvent).where(lambda event: event.type.startswith(prefix))
|
|
401
406
|
|
|
402
407
|
def subscribe(self, event: RpcEventDefinition[_T]) -> AsyncRpcEvents[_T]:
|
|
403
408
|
"""Subscribes to one event of this RPC, with validated data, on the client's shared event stream."""
|
|
409
|
+
from .types import RpcEvent
|
|
410
|
+
|
|
404
411
|
_check_event(self.definition, event)
|
|
405
412
|
wire = self.definition.event_type(event)
|
|
406
413
|
return AsyncRpcEvents(self._client.events.of_type(RpcEvent).where(lambda raw: raw.type == wire), event)
|
opencode/lib/__init__.py
ADDED
opencode/lib/_ready.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""``client.wait_until_ready``: poll until agents and MCP servers have loaded.
|
|
2
|
+
|
|
3
|
+
OpenCode loads agents and connects MCP servers in the background after it starts listening, so a freshly booted
|
|
4
|
+
server can answer requests before the agent a caller needs exists.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import anyio
|
|
13
|
+
|
|
14
|
+
from .._exceptions import APIConnectionError, ServiceUnavailableError
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
|
|
19
|
+
from .._client import AsyncOpencode, Opencode
|
|
20
|
+
from ..types import AgentListResponse, McpListResponse
|
|
21
|
+
|
|
22
|
+
__all__ = ["POLL_INTERVAL", "async_wait_until_ready", "wait_until_ready"]
|
|
23
|
+
|
|
24
|
+
POLL_INTERVAL = 0.25
|
|
25
|
+
"""Seconds between polls."""
|
|
26
|
+
|
|
27
|
+
_TRANSIENT = (APIConnectionError, ServiceUnavailableError)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _missing(
|
|
31
|
+
agents: frozenset[str],
|
|
32
|
+
mcp_servers: frozenset[str],
|
|
33
|
+
agent_list: AgentListResponse | None,
|
|
34
|
+
mcp_list: McpListResponse | None,
|
|
35
|
+
) -> list[str]:
|
|
36
|
+
missing: list[str] = []
|
|
37
|
+
if agents:
|
|
38
|
+
present = {agent.id for agent in agent_list.data} if agent_list is not None else set[str]()
|
|
39
|
+
missing += [f"agent {name!r}" for name in sorted(agents - present)]
|
|
40
|
+
if mcp_servers:
|
|
41
|
+
connected = (
|
|
42
|
+
{server.name for server in mcp_list.data if server.status.status == "connected"}
|
|
43
|
+
if mcp_list is not None
|
|
44
|
+
else set[str]()
|
|
45
|
+
)
|
|
46
|
+
missing += [f"MCP server {name!r}" for name in sorted(mcp_servers - connected)]
|
|
47
|
+
return missing
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def wait_until_ready(
|
|
51
|
+
client: Opencode, *, agents: Iterable[str] = (), mcp_servers: Iterable[str] = (), timeout: float = 60
|
|
52
|
+
) -> None:
|
|
53
|
+
wanted_agents, wanted_mcp = frozenset(agents), frozenset(mcp_servers)
|
|
54
|
+
deadline = time.monotonic() + timeout
|
|
55
|
+
while True:
|
|
56
|
+
agent_list = mcp_list = None
|
|
57
|
+
missing = ["server"]
|
|
58
|
+
try:
|
|
59
|
+
client.server.info()
|
|
60
|
+
agent_list = client.agent.list() if wanted_agents else None
|
|
61
|
+
mcp_list = client.mcp.list() if wanted_mcp else None
|
|
62
|
+
missing = _missing(wanted_agents, wanted_mcp, agent_list, mcp_list)
|
|
63
|
+
except _TRANSIENT:
|
|
64
|
+
pass
|
|
65
|
+
if not missing:
|
|
66
|
+
return
|
|
67
|
+
if time.monotonic() >= deadline:
|
|
68
|
+
raise TimeoutError(f"OpenCode not ready after {timeout}s; still waiting for {', '.join(missing)}")
|
|
69
|
+
time.sleep(POLL_INTERVAL)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def async_wait_until_ready(
|
|
73
|
+
client: AsyncOpencode, *, agents: Iterable[str] = (), mcp_servers: Iterable[str] = (), timeout: float = 60
|
|
74
|
+
) -> None:
|
|
75
|
+
wanted_agents, wanted_mcp = frozenset(agents), frozenset(mcp_servers)
|
|
76
|
+
deadline = time.monotonic() + timeout
|
|
77
|
+
while True:
|
|
78
|
+
agent_list = mcp_list = None
|
|
79
|
+
missing = ["server"]
|
|
80
|
+
try:
|
|
81
|
+
await client.server.info()
|
|
82
|
+
agent_list = await client.agent.list() if wanted_agents else None
|
|
83
|
+
mcp_list = await client.mcp.list() if wanted_mcp else None
|
|
84
|
+
missing = _missing(wanted_agents, wanted_mcp, agent_list, mcp_list)
|
|
85
|
+
except _TRANSIENT:
|
|
86
|
+
pass
|
|
87
|
+
if not missing:
|
|
88
|
+
return
|
|
89
|
+
if time.monotonic() >= deadline:
|
|
90
|
+
raise TimeoutError(f"OpenCode not ready after {timeout}s; still waiting for {', '.join(missing)}")
|
|
91
|
+
await anyio.sleep(POLL_INTERVAL)
|
opencode/lib/_session.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""``client.session.run``: create or reuse a session, prompt it, wait until idle, and collect the result.
|
|
2
|
+
|
|
3
|
+
The generated ``SessionResource`` / ``AsyncSessionResource`` inherit from the classes here (see
|
|
4
|
+
``codegen/render.py``, ``HELPER_BASES``), so these methods sit next to the generated ones.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING, cast
|
|
12
|
+
|
|
13
|
+
import anyio
|
|
14
|
+
|
|
15
|
+
from .._resource import AsyncAPIResource, SyncAPIResource
|
|
16
|
+
from .._types import NOT_GIVEN, NotGiven, Timeout
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
|
|
21
|
+
from ..resources.session import AsyncSessionResource, SessionResource
|
|
22
|
+
from ..types import (
|
|
23
|
+
AgentID,
|
|
24
|
+
ModelRef,
|
|
25
|
+
PromptInputFileAttachment,
|
|
26
|
+
SessionID,
|
|
27
|
+
SessionInfo,
|
|
28
|
+
SessionMessageAssistant,
|
|
29
|
+
SessionMessageInfo,
|
|
30
|
+
SessionStructuredError,
|
|
31
|
+
TokenUsageInfo,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = ["AsyncSessionHelpers", "RunResult", "SessionHelpers"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class RunResult:
|
|
39
|
+
"""What :meth:`SessionHelpers.run` returns."""
|
|
40
|
+
|
|
41
|
+
session: SessionInfo
|
|
42
|
+
"""The session as it was after the run. ``session.cost`` and ``session.tokens`` cover the whole session."""
|
|
43
|
+
messages: list[SessionMessageInfo] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
|
44
|
+
"""Messages this run added, oldest first, starting with the prompt."""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def assistant_messages(self) -> list[SessionMessageAssistant]:
|
|
48
|
+
"""The assistant messages of this run, oldest first."""
|
|
49
|
+
from ..types import SessionMessageAssistant
|
|
50
|
+
|
|
51
|
+
return [message for message in self.messages if isinstance(message, SessionMessageAssistant)]
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def text(self) -> str:
|
|
55
|
+
"""Text of the last assistant message, or ``""`` when the agent produced none."""
|
|
56
|
+
from ..types import SessionMessageAssistantText
|
|
57
|
+
|
|
58
|
+
for message in reversed(self.assistant_messages):
|
|
59
|
+
parts = [item.text for item in message.content if isinstance(item, SessionMessageAssistantText)]
|
|
60
|
+
if parts:
|
|
61
|
+
return "".join(parts)
|
|
62
|
+
return ""
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def outcome(self) -> str | None:
|
|
66
|
+
"""``succeeded``, ``failed`` or ``interrupted``, from the run's idle marker or the session."""
|
|
67
|
+
from ..types import SessionMessageIdle
|
|
68
|
+
|
|
69
|
+
for message in reversed(self.messages):
|
|
70
|
+
if isinstance(message, SessionMessageIdle):
|
|
71
|
+
return message.outcome
|
|
72
|
+
return self.session.outcome
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def error(self) -> SessionStructuredError | None:
|
|
76
|
+
"""Why the run failed, from its idle marker or last assistant message, or ``None``."""
|
|
77
|
+
from ..types import SessionMessageAssistant, SessionMessageIdle
|
|
78
|
+
|
|
79
|
+
for message in reversed(self.messages):
|
|
80
|
+
if isinstance(message, (SessionMessageIdle, SessionMessageAssistant)) and message.error is not None:
|
|
81
|
+
return message.error
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def cost(self) -> float:
|
|
86
|
+
"""Cost of this run in USD, summed over its assistant messages."""
|
|
87
|
+
return sum(message.cost or 0.0 for message in self.assistant_messages)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def tokens(self) -> TokenUsageInfo:
|
|
91
|
+
"""Token usage of this run, summed over its assistant messages."""
|
|
92
|
+
from ..types import TokenUsageInfo, TokenUsageInfoCache
|
|
93
|
+
|
|
94
|
+
total = TokenUsageInfo(input=0, output=0, reasoning=0, cache=TokenUsageInfoCache(read=0, write=0))
|
|
95
|
+
for message in self.assistant_messages:
|
|
96
|
+
usage = message.tokens
|
|
97
|
+
if usage is None:
|
|
98
|
+
continue
|
|
99
|
+
total.input += usage.input
|
|
100
|
+
total.output += usage.output
|
|
101
|
+
total.reasoning += usage.reasoning
|
|
102
|
+
total.cache.read += usage.cache.read
|
|
103
|
+
total.cache.write += usage.cache.write
|
|
104
|
+
return total
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _new_messages(newest_first: list[SessionMessageInfo], marker: str | None) -> list[SessionMessageInfo]:
|
|
108
|
+
from ..types import SessionMessageUser
|
|
109
|
+
|
|
110
|
+
collected: list[SessionMessageInfo] = []
|
|
111
|
+
for message in newest_first:
|
|
112
|
+
if marker is not None and message.id == marker:
|
|
113
|
+
break
|
|
114
|
+
collected.append(message)
|
|
115
|
+
collected.reverse()
|
|
116
|
+
# Drop anything that was already queued before our prompt.
|
|
117
|
+
for index, message in enumerate(collected):
|
|
118
|
+
if isinstance(message, SessionMessageUser):
|
|
119
|
+
return collected[index:]
|
|
120
|
+
return collected
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class SessionHelpers(SyncAPIResource):
|
|
124
|
+
"""Hand-written helpers on ``client.session``."""
|
|
125
|
+
|
|
126
|
+
def run(
|
|
127
|
+
self,
|
|
128
|
+
text: str,
|
|
129
|
+
*,
|
|
130
|
+
agent: AgentID | None = None,
|
|
131
|
+
model: ModelRef | None = None,
|
|
132
|
+
files: Sequence[PromptInputFileAttachment] | NotGiven = NOT_GIVEN,
|
|
133
|
+
session_id: SessionID | None = None,
|
|
134
|
+
timeout: Timeout = None,
|
|
135
|
+
) -> RunResult:
|
|
136
|
+
"""Runs one prompt to completion and returns what the agent produced.
|
|
137
|
+
|
|
138
|
+
Creates a session with ``agent`` and ``model`` (or reuses ``session_id``, switching its agent and model when
|
|
139
|
+
given), sends ``text``, blocks until the session is idle, and reads back the new messages and the session's
|
|
140
|
+
usage.
|
|
141
|
+
|
|
142
|
+
If the wait is interrupted (``KeyboardInterrupt``, a timeout, or any other error), the session is
|
|
143
|
+
interrupted on the server before the error propagates, so no agent keeps running unattended.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
text: The prompt.
|
|
147
|
+
agent: Agent for a new session, or to switch an existing one to.
|
|
148
|
+
model: Model for a new session, or to switch an existing one to. Set it with ``agent`` so the session
|
|
149
|
+
does not start on another model.
|
|
150
|
+
files: File attachments; images can be ``data:`` URLs.
|
|
151
|
+
session_id: Continue this session instead of creating one.
|
|
152
|
+
timeout: Seconds to wait for the session to become idle. ``None`` waits forever.
|
|
153
|
+
|
|
154
|
+
Example::
|
|
155
|
+
|
|
156
|
+
result = client.session.run("Summarize README.md", agent="build")
|
|
157
|
+
print(result.text, result.cost)
|
|
158
|
+
"""
|
|
159
|
+
resource = cast("SessionResource", self)
|
|
160
|
+
if session_id is None:
|
|
161
|
+
session = resource.create(agent=agent or NOT_GIVEN, model=model or NOT_GIVEN)
|
|
162
|
+
session_id = session.id
|
|
163
|
+
marker = None
|
|
164
|
+
else:
|
|
165
|
+
if agent is not None:
|
|
166
|
+
resource.switch_agent(session_id, agent=agent)
|
|
167
|
+
if model is not None:
|
|
168
|
+
resource.switch_model(session_id, model=model)
|
|
169
|
+
latest = self._client.message.list(session_id, order="desc", limit=1).data
|
|
170
|
+
marker = latest[0].id if latest else None
|
|
171
|
+
|
|
172
|
+
resource.prompt(session_id, text=text, files=files)
|
|
173
|
+
try:
|
|
174
|
+
resource.wait(session_id, timeout=timeout)
|
|
175
|
+
except BaseException:
|
|
176
|
+
with contextlib.suppress(Exception):
|
|
177
|
+
resource.interrupt(session_id)
|
|
178
|
+
raise
|
|
179
|
+
|
|
180
|
+
newest_first: list[SessionMessageInfo] = []
|
|
181
|
+
for message in self._client.message.list(session_id, order="desc"):
|
|
182
|
+
if marker is not None and message.id == marker:
|
|
183
|
+
break
|
|
184
|
+
newest_first.append(message)
|
|
185
|
+
return RunResult(session=resource.get(session_id), messages=_new_messages(newest_first, marker))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class AsyncSessionHelpers(AsyncAPIResource):
|
|
189
|
+
"""Hand-written helpers on ``client.session`` of :class:`opencode.AsyncOpencode`."""
|
|
190
|
+
|
|
191
|
+
async def run(
|
|
192
|
+
self,
|
|
193
|
+
text: str,
|
|
194
|
+
*,
|
|
195
|
+
agent: AgentID | None = None,
|
|
196
|
+
model: ModelRef | None = None,
|
|
197
|
+
files: Sequence[PromptInputFileAttachment] | NotGiven = NOT_GIVEN,
|
|
198
|
+
session_id: SessionID | None = None,
|
|
199
|
+
timeout: Timeout = None,
|
|
200
|
+
) -> RunResult:
|
|
201
|
+
"""Runs one prompt to completion. See :meth:`SessionHelpers.run`.
|
|
202
|
+
|
|
203
|
+
Cancelling the task interrupts the session on the server before the cancellation propagates.
|
|
204
|
+
"""
|
|
205
|
+
resource = cast("AsyncSessionResource", self)
|
|
206
|
+
if session_id is None:
|
|
207
|
+
session = await resource.create(agent=agent or NOT_GIVEN, model=model or NOT_GIVEN)
|
|
208
|
+
session_id = session.id
|
|
209
|
+
marker = None
|
|
210
|
+
else:
|
|
211
|
+
if agent is not None:
|
|
212
|
+
await resource.switch_agent(session_id, agent=agent)
|
|
213
|
+
if model is not None:
|
|
214
|
+
await resource.switch_model(session_id, model=model)
|
|
215
|
+
latest = (await self._client.message.list(session_id, order="desc", limit=1)).data
|
|
216
|
+
marker = latest[0].id if latest else None
|
|
217
|
+
|
|
218
|
+
await resource.prompt(session_id, text=text, files=files)
|
|
219
|
+
try:
|
|
220
|
+
await resource.wait(session_id, timeout=timeout)
|
|
221
|
+
except BaseException:
|
|
222
|
+
with anyio.CancelScope(shield=True), contextlib.suppress(Exception):
|
|
223
|
+
await resource.interrupt(session_id)
|
|
224
|
+
raise
|
|
225
|
+
|
|
226
|
+
newest_first: list[SessionMessageInfo] = []
|
|
227
|
+
async for message in self._client.message.list(session_id, order="desc"):
|
|
228
|
+
if marker is not None and message.id == marker:
|
|
229
|
+
break
|
|
230
|
+
newest_first.append(message)
|
|
231
|
+
return RunResult(session=await resource.get(session_id), messages=_new_messages(newest_first, marker))
|