opencode-python-sdk 0.1.3__tar.gz → 0.2.0__tar.gz
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_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/PKG-INFO +50 -1
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/README.md +49 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/__init__.py +14 -1
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_base_client.py +24 -3
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_client.py +67 -2
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_events.py +5 -2
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_rpc.py +9 -2
- opencode_python_sdk-0.2.0/src/opencode/lib/__init__.py +5 -0
- opencode_python_sdk-0.2.0/src/opencode/lib/_ready.py +91 -0
- opencode_python_sdk-0.2.0/src/opencode/lib/_session.py +231 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/agent.py +13 -11
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/command.py +9 -7
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/config.py +12 -10
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/credential.py +13 -11
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/debug.py +9 -7
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/event.py +9 -5
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/experimental.py +31 -29
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/file.py +15 -13
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/form.py +9 -7
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/generate.py +9 -7
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/integration.py +28 -26
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/location.py +9 -7
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/mcp.py +13 -11
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/message.py +9 -8
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/migration.py +10 -12
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/model.py +12 -10
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/permission.py +29 -27
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/plugin.py +12 -10
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/project.py +14 -12
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/provider.py +13 -11
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/pty.py +23 -21
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/reference.py +9 -7
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/rpc.py +9 -7
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/server.py +14 -12
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/session.py +92 -90
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/shell.py +19 -17
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/skill.py +9 -7
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/vcs.py +22 -20
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/voice.py +9 -7
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/websearch.py +13 -11
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/worktree.py +13 -11
- opencode_python_sdk-0.2.0/src/opencode/testing.py +508 -0
- opencode_python_sdk-0.2.0/tests/test_helpers.py +252 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_live.py +22 -1
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_models.py +9 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/.gitignore +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/CHANGELOG.md +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/LICENSE +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/api.md +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/pyproject.toml +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/.generated.json +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_constants.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_errors.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_exceptions.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_models.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_pagination.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_pty.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_qs.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_resource.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_server.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_streaming.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_types.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_unions.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/_version.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/py.typed +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/resources/__init__.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/types/__init__.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/src/opencode/types/_generated.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/__init__.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/conftest.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_async_client.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_client.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_docs_coverage.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_encoding.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_events.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_pagination.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_pty.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_rpc.py +0 -0
- {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.0}/tests/test_streaming.py +0 -0
|
@@ -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/
|
|
@@ -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:
|
|
@@ -313,6 +313,55 @@ client.session.get("ses_...", timeout=5.0) # per call
|
|
|
313
313
|
|
|
314
314
|
The default timeout is 10 minutes with a 10 second connect timeout.
|
|
315
315
|
|
|
316
|
+
## Running an agent headless
|
|
317
|
+
|
|
318
|
+
`client.session.run` does the usual "prompt an agent and wait" round trip in one call: it creates a session (or
|
|
319
|
+
reuses `session_id`), sends the prompt, blocks until the session is idle, and returns the new messages with the
|
|
320
|
+
final text and usage. `client.wait_until_ready` waits until agents and MCP servers have loaded, which happens
|
|
321
|
+
asynchronously after the server boots. Both have async versions on `AsyncOpencode`.
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
from opencode import Opencode
|
|
325
|
+
from opencode.types import ModelRef, PromptInputFileAttachment
|
|
326
|
+
|
|
327
|
+
with Opencode("http://127.0.0.1:4096", password="...") as client:
|
|
328
|
+
client.wait_until_ready(agents=["reviewer"], mcp_servers=["github"], timeout=60)
|
|
329
|
+
result = client.session.run(
|
|
330
|
+
"Review the attached diff",
|
|
331
|
+
agent="reviewer",
|
|
332
|
+
model=ModelRef(provider_id="anthropic", id="claude-sonnet-5-5"), # set it with the agent
|
|
333
|
+
files=[PromptInputFileAttachment(uri="data:text/plain;base64,...", name="diff.patch")],
|
|
334
|
+
timeout=600,
|
|
335
|
+
)
|
|
336
|
+
print(result.outcome, result.text)
|
|
337
|
+
print(result.cost, result.tokens.input, result.tokens.output) # this run; result.session has the totals
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
If the wait ends early (Ctrl-C, task cancellation, `timeout`, a connection error), `run` interrupts the session
|
|
341
|
+
on the server before re-raising, so the agent does not keep running unattended.
|
|
342
|
+
|
|
343
|
+
## Testing your code
|
|
344
|
+
|
|
345
|
+
`opencode.testing.FakeServer` is an in-process fake of the server: an `httpx` transport that keeps sessions,
|
|
346
|
+
messages and events in memory and answers with the same payloads as the real server. No process, no model.
|
|
347
|
+
|
|
348
|
+
```python
|
|
349
|
+
from opencode import Opencode
|
|
350
|
+
from opencode.testing import FakeServer
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_summarize() -> None:
|
|
354
|
+
fake = FakeServer(reply=lambda prompt: f"summary of {prompt!r}")
|
|
355
|
+
client = Opencode.fake(fake) # same as Opencode(transport=fake)
|
|
356
|
+
|
|
357
|
+
assert client.session.run("README").text == "summary of 'README'"
|
|
358
|
+
assert fake.prompts == ["README"]
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
`fake.fail(method, path_regex, status=..., exception=...)` injects errors, `fake.add_agent(..., after_polls=n)`
|
|
362
|
+
and `fake.set_mcp_status(...)` simulate slow boots, and `fake.requests` records every request. Routes the fake
|
|
363
|
+
does not implement answer `404`.
|
|
364
|
+
|
|
316
365
|
## Running a server
|
|
317
366
|
|
|
318
367
|
`OpencodeServer` runs `opencode serve` as a child process on a free local port with a random password:
|
|
@@ -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}")
|
|
@@ -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."""
|
|
@@ -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
|
*,
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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)
|