opencode-python-sdk 0.1.3__tar.gz → 0.2.2__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.
Files changed (85) hide show
  1. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/PKG-INFO +71 -1
  2. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/README.md +70 -0
  3. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/pyproject.toml +4 -2
  4. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/__init__.py +19 -1
  5. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/_base_client.py +24 -3
  6. opencode_python_sdk-0.2.2/src/opencode/_client.py +361 -0
  7. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/_events.py +5 -2
  8. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/_rpc.py +9 -2
  9. opencode_python_sdk-0.2.2/src/opencode/_service.py +1018 -0
  10. opencode_python_sdk-0.2.2/src/opencode/lib/__init__.py +5 -0
  11. opencode_python_sdk-0.2.2/src/opencode/lib/_ready.py +91 -0
  12. opencode_python_sdk-0.2.2/src/opencode/lib/_session.py +231 -0
  13. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/agent.py +13 -11
  14. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/command.py +9 -7
  15. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/config.py +12 -10
  16. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/credential.py +13 -11
  17. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/debug.py +9 -7
  18. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/event.py +9 -5
  19. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/experimental.py +31 -29
  20. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/file.py +15 -13
  21. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/form.py +9 -7
  22. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/generate.py +9 -7
  23. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/integration.py +28 -26
  24. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/location.py +9 -7
  25. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/mcp.py +13 -11
  26. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/message.py +9 -8
  27. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/migration.py +10 -12
  28. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/model.py +12 -10
  29. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/permission.py +29 -27
  30. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/plugin.py +12 -10
  31. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/project.py +14 -12
  32. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/provider.py +13 -11
  33. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/pty.py +23 -21
  34. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/reference.py +9 -7
  35. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/rpc.py +9 -7
  36. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/server.py +14 -12
  37. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/session.py +92 -90
  38. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/shell.py +19 -17
  39. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/skill.py +9 -7
  40. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/vcs.py +22 -20
  41. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/voice.py +9 -7
  42. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/websearch.py +13 -11
  43. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/worktree.py +13 -11
  44. opencode_python_sdk-0.2.2/src/opencode/service.py +50 -0
  45. opencode_python_sdk-0.2.2/src/opencode/testing.py +508 -0
  46. opencode_python_sdk-0.2.2/tests/fake_service.py +77 -0
  47. opencode_python_sdk-0.2.2/tests/test_helpers.py +252 -0
  48. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/tests/test_live.py +22 -1
  49. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/tests/test_models.py +9 -0
  50. opencode_python_sdk-0.2.2/tests/test_service.py +288 -0
  51. opencode_python_sdk-0.2.2/tests/test_service_live.py +67 -0
  52. opencode_python_sdk-0.1.3/src/opencode/_client.py +0 -190
  53. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/.gitignore +0 -0
  54. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/CHANGELOG.md +0 -0
  55. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/LICENSE +0 -0
  56. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/api.md +0 -0
  57. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/.generated.json +0 -0
  58. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/_constants.py +0 -0
  59. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/_errors.py +0 -0
  60. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/_exceptions.py +0 -0
  61. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/_models.py +0 -0
  62. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/_pagination.py +0 -0
  63. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/_pty.py +0 -0
  64. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/_qs.py +0 -0
  65. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/_resource.py +0 -0
  66. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/_server.py +0 -0
  67. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/_streaming.py +0 -0
  68. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/_types.py +0 -0
  69. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/_unions.py +0 -0
  70. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/_version.py +0 -0
  71. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/py.typed +0 -0
  72. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/resources/__init__.py +0 -0
  73. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/types/__init__.py +0 -0
  74. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/src/opencode/types/_generated.py +0 -0
  75. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/tests/__init__.py +0 -0
  76. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/tests/conftest.py +0 -0
  77. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/tests/test_async_client.py +0 -0
  78. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/tests/test_client.py +0 -0
  79. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/tests/test_docs_coverage.py +0 -0
  80. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/tests/test_encoding.py +0 -0
  81. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/tests/test_events.py +0 -0
  82. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/tests/test_pagination.py +0 -0
  83. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/tests/test_pty.py +0 -0
  84. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/tests/test_rpc.py +0 -0
  85. {opencode_python_sdk-0.1.3 → opencode_python_sdk-0.2.2}/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.1.3
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
@@ -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:
@@ -333,6 +382,27 @@ with server:
333
382
  The server runs in `--stdio` mode, so it also exits when the Python process dies. `AsyncOpencodeServer` is the
334
383
  async equivalent.
335
384
 
385
+ ### The background service
386
+
387
+ OpenCode can also run one shared background server per user (`opencode serve --service`), which registers itself in
388
+ `$XDG_STATE_HOME/opencode/service.json`. `Opencode.from_service()` connects to it, starting it first if it is not
389
+ running:
390
+
391
+ ```python
392
+ from opencode import Opencode, service
393
+
394
+ with Opencode.from_service(version="2.1.2") as client: # replaces a service running another version
395
+ print(client.server.info().version)
396
+
397
+ endpoint = service.discover() # an Endpoint, or None; never starts anything
398
+ service.stop(pty="handoff") # hand persistent terminals to the next service instead of closing them
399
+ ```
400
+
401
+ `service.ensure()` returns the `Endpoint` (URL and credentials) without creating a client, and `service.headers()`
402
+ gives its `Authorization` header for other HTTP clients. Concurrent callers are safe: they race to start the service
403
+ and all get the one that won. `await AsyncOpencode.from_service()`, `service.async_discover()`,
404
+ `service.async_ensure()` and `service.async_stop()` are the async equivalents.
405
+
336
406
  ## Advanced
337
407
 
338
408
  ```python
@@ -55,9 +55,11 @@ dev = [
55
55
  "trio>=0.31",
56
56
  "websockets>=14",
57
57
  ]
58
+ # Pillow (via [imaging]) only ships patched releases for Python 3.11+, so docs builds need 3.11+.
58
59
  docs = [
59
- "mkdocs-material[imaging]>=9.6",
60
- "mkdocstrings[python]>=0.30",
60
+ "mkdocs-material[imaging]>=9.6; python_version >= '3.11'",
61
+ "mkdocstrings[python]>=0.30; python_version >= '3.11'",
62
+ "pillow>=13; python_version >= '3.11'",
61
63
  ]
62
64
 
63
65
  [build-system]
@@ -14,7 +14,9 @@ Quick start::
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- from . import types
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from . import service
18
20
  from ._client import AsyncOpencode, Opencode
19
21
 
20
22
  # <generated:error-imports>
@@ -77,6 +79,7 @@ from ._models import BaseModel
77
79
  from ._pagination import AsyncCursorPage, AsyncPaginator, SyncCursorPage
78
80
  from ._pty import AsyncPtyConnection, PtyConnection, PtyConnectionError
79
81
  from ._server import AsyncOpencodeServer, OpencodeServer, ServerStartError
82
+ from ._service import Endpoint, ServiceError
80
83
  from ._streaming import AsyncStream, Stream
81
84
  from ._types import NOT_GIVEN, NotGiven
82
85
  from ._version import __version__
@@ -108,6 +111,9 @@ from ._rpc import (
108
111
  rpc_method,
109
112
  )
110
113
 
114
+ if TYPE_CHECKING:
115
+ from . import types
116
+
111
117
  # <generated:api-version>
112
118
  API_VERSION = "2.1.2"
113
119
  """The OpenCode server version this client was generated from."""
@@ -132,6 +138,7 @@ __all__ = [
132
138
  "BaseModel",
133
139
  "ConflictError",
134
140
  "ContentTooLargeError",
141
+ "Endpoint",
135
142
  "InternalServerError",
136
143
  "NotFoundError",
137
144
  "NotGiven",
@@ -144,11 +151,13 @@ __all__ = [
144
151
  "PtyConnectionError",
145
152
  "RateLimitError",
146
153
  "ServerStartError",
154
+ "ServiceError",
147
155
  "ServiceUnavailableError",
148
156
  "Stream",
149
157
  "SyncCursorPage",
150
158
  "UnprocessableEntityError",
151
159
  "__version__",
160
+ "service",
152
161
  "types",
153
162
  # Shared event stream and typed plugin RPC
154
163
  "SUBSCRIBER_CAPACITY",
@@ -206,3 +215,12 @@ __all__ = [
206
215
  "WorktreeError",
207
216
  # </generated:error-exports>
208
217
  ]
218
+
219
+
220
+ def __getattr__(name: str) -> Any:
221
+ # `opencode.types` builds several hundred models, so it is imported on first use rather than with the package.
222
+ if name == "types":
223
+ import importlib
224
+
225
+ return importlib.import_module(".types", __name__)
226
+ 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
- self._http = http_client or httpx.Client(limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True)
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
- self._http = http_client or httpx.AsyncClient(limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True)
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."""
@@ -0,0 +1,361 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import cached_property
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from typing_extensions import Self
7
+
8
+ from ._base_client import AsyncAPIClient, SyncAPIClient
9
+ from ._constants import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, DEFAULT_USERNAME
10
+ from ._events import AsyncEventHub, EventHub
11
+ from ._rpc import AsyncTypedRpcResource, TypedRpcResource
12
+ from ._service import (
13
+ EnsureReason,
14
+ ServiceError,
15
+ Version,
16
+ async_discover,
17
+ async_ensure,
18
+ discover as discover_service,
19
+ ensure as ensure_service,
20
+ )
21
+ from ._types import NOT_GIVEN, NotGiven, Timeout
22
+ from .resources import AsyncResources, SyncResources
23
+
24
+ if TYPE_CHECKING:
25
+ import os
26
+ from collections.abc import Callable, Iterable, Mapping, Sequence
27
+
28
+ import httpx
29
+
30
+ from .testing import FakeServer
31
+
32
+ __all__ = ["AsyncOpencode", "Opencode"]
33
+
34
+ FAKE_BASE_URL = "http://opencode.fake"
35
+ """Base URL of clients made by ``Opencode.fake()``."""
36
+
37
+
38
+ class Opencode(SyncAPIClient, SyncResources):
39
+ """Synchronous client for an OpenCode server.
40
+
41
+ Args:
42
+ base_url: Server URL, for example ``http://127.0.0.1:4096``. Defaults to ``OPENCODE_BASE_URL``.
43
+ password: Server password. Defaults to ``OPENCODE_PASSWORD``, then ``OPENCODE_SERVER_PASSWORD``. Pass ``""``
44
+ to send no credentials.
45
+ username: Basic auth user name. The OpenCode server uses ``opencode``.
46
+ directory: Default project directory. Sent as the ``location`` of every request that accepts one, unless
47
+ the call passes its own ``location``.
48
+ timeout: Default timeout in seconds, or an ``httpx.Timeout``. Event streams only apply the connect timeout.
49
+ max_retries: Retries for connection failures and transient statuses. Requests that may have changed state
50
+ on the server are only retried when the server refused them before acting (429 and 503).
51
+ default_headers: Headers sent with every request.
52
+ default_query: Query parameters sent with every request.
53
+ http_client: A preconfigured ``httpx.Client`` (proxies, custom transports, event hooks). The caller keeps
54
+ ownership and must close it.
55
+ transport: An ``httpx`` transport for the client's own connection pool, such as
56
+ :class:`opencode.testing.FakeServer`. Cannot be combined with ``http_client``.
57
+
58
+ Example::
59
+
60
+ from opencode import Opencode
61
+
62
+ with Opencode("http://127.0.0.1:4096", password="...") as client:
63
+ for agent in client.agent.list():
64
+ print(agent.id)
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ base_url: str | httpx.URL | None = None,
70
+ *,
71
+ password: str | None = None,
72
+ username: str = DEFAULT_USERNAME,
73
+ directory: str | os.PathLike[str] | None = None,
74
+ timeout: Timeout = DEFAULT_TIMEOUT,
75
+ max_retries: int = DEFAULT_MAX_RETRIES,
76
+ default_headers: Mapping[str, str] | None = None,
77
+ default_query: Mapping[str, object] | None = None,
78
+ http_client: httpx.Client | None = None,
79
+ transport: httpx.BaseTransport | None = None,
80
+ ) -> None:
81
+ super().__init__(
82
+ base_url=base_url,
83
+ password=password,
84
+ username=username,
85
+ directory=directory,
86
+ timeout=timeout,
87
+ max_retries=max_retries,
88
+ default_headers=default_headers,
89
+ default_query=default_query,
90
+ http_client=http_client,
91
+ transport=transport,
92
+ )
93
+
94
+ @classmethod
95
+ def from_service(
96
+ cls,
97
+ *,
98
+ start: bool = True,
99
+ file: str | os.PathLike[str] | None = None,
100
+ version: Version | None = None,
101
+ command: Sequence[str] | None = None,
102
+ env: Mapping[str, str] | None = None,
103
+ on_start: Callable[[EnsureReason, str | None], None] | None = None,
104
+ directory: str | os.PathLike[str] | None = None,
105
+ timeout: Timeout = DEFAULT_TIMEOUT,
106
+ max_retries: int = DEFAULT_MAX_RETRIES,
107
+ default_headers: Mapping[str, str] | None = None,
108
+ default_query: Mapping[str, object] | None = None,
109
+ ) -> Self:
110
+ """Connect to the local background service, starting ``opencode serve --service`` if needed.
111
+
112
+ Uses :func:`opencode.service.ensure`, or :func:`opencode.service.discover` when ``start`` is false, and
113
+ sends the service's password with every request.
114
+
115
+ Args:
116
+ start: Start or replace the service when no matching one is running. When false, raise instead.
117
+ file: Registration file. Defaults to ``$XDG_STATE_HOME/opencode/service.json``.
118
+ version: Required exact version, or a predicate on the service version.
119
+ command: Service command and arguments, used when starting it.
120
+ env: Variables added to the environment of a started service.
121
+ on_start: Called once before a service process is started.
122
+ directory: Default project directory, as in the constructor.
123
+ timeout: Default request timeout, as in the constructor.
124
+ max_retries: Retries for failed requests, as in the constructor.
125
+ default_headers: Headers sent with every request.
126
+ default_query: Query parameters sent with every request.
127
+
128
+ Raises:
129
+ ServiceError: No matching service is running and ``start`` is false, or it could not be started.
130
+
131
+ Example::
132
+
133
+ with Opencode.from_service() as client:
134
+ print(client.server.info().version)
135
+ """
136
+ if start:
137
+ endpoint = ensure_service(file, version, command=command, env=env, on_start=on_start)
138
+ else:
139
+ found = discover_service(file, version)
140
+ if found is None:
141
+ raise ServiceError("No matching OpenCode background service is running")
142
+ endpoint = found
143
+ return cls(
144
+ endpoint.url,
145
+ # An empty password sends no credentials instead of falling back to OPENCODE_PASSWORD.
146
+ password="" if endpoint.auth is None else endpoint.auth.password,
147
+ username=DEFAULT_USERNAME if endpoint.auth is None else endpoint.auth.username,
148
+ directory=directory,
149
+ timeout=timeout,
150
+ max_retries=max_retries,
151
+ default_headers=default_headers,
152
+ default_query=default_query,
153
+ )
154
+
155
+ @cached_property
156
+ def events(self) -> EventHub:
157
+ """The shared event stream: one connection for every subscriber. See :class:`opencode.EventHub`."""
158
+ return EventHub(self)
159
+
160
+ @cached_property
161
+ def rpc(self) -> TypedRpcResource:
162
+ """Plugin RPC calls: ``client.rpc.call(...)``, or ``client.rpc(Definition)`` for typed access."""
163
+ return TypedRpcResource(self)
164
+
165
+ @classmethod
166
+ def fake(cls, server: FakeServer | None = None, **kwargs: Any) -> Self:
167
+ """Returns a client wired to an in-process :class:`opencode.testing.FakeServer`, for tests.
168
+
169
+ Pass your own ``server`` to configure replies and inspect requests; other arguments go to the constructor.
170
+ """
171
+ from .testing import FakeServer
172
+
173
+ kwargs.setdefault("password", "")
174
+ return cls(FAKE_BASE_URL, transport=server if server is not None else FakeServer(), **kwargs)
175
+
176
+ def wait_until_ready(
177
+ self, *, agents: Iterable[str] = (), mcp_servers: Iterable[str] = (), timeout: float = 60
178
+ ) -> None:
179
+ """Blocks until the server answers, the named ``agents`` are listed and the named ``mcp_servers`` are
180
+ connected.
181
+
182
+ Agents load asynchronously after the server starts, so call this before creating sessions on a freshly
183
+ booted server. Connection errors and 503s while the server boots count as not ready yet.
184
+
185
+ Raises:
186
+ TimeoutError: Something was still missing after ``timeout`` seconds. The message names it.
187
+ """
188
+ from .lib._ready import wait_until_ready
189
+
190
+ wait_until_ready(self, agents=agents, mcp_servers=mcp_servers, timeout=timeout)
191
+
192
+ def with_options(
193
+ self,
194
+ *,
195
+ base_url: str | httpx.URL | None = None,
196
+ password: str | NotGiven | None = NOT_GIVEN,
197
+ directory: str | os.PathLike[str] | NotGiven | None = NOT_GIVEN,
198
+ timeout: Timeout | NotGiven = NOT_GIVEN,
199
+ max_retries: int | None = None,
200
+ default_headers: Mapping[str, str] | None = None,
201
+ default_query: Mapping[str, object] | None = None,
202
+ ) -> Self:
203
+ """Returns a client with some options changed. It shares this client's connection pool.
204
+
205
+ ``default_headers`` and ``default_query`` are merged over the current values.
206
+ """
207
+ client = type(self)(
208
+ base_url=base_url or self.base_url,
209
+ password=self.password or "" if isinstance(password, NotGiven) else password,
210
+ username=self.username,
211
+ directory=self.directory if isinstance(directory, NotGiven) else directory,
212
+ timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
213
+ max_retries=self.max_retries if max_retries is None else max_retries,
214
+ default_headers={**self._default_headers, **(default_headers or {})},
215
+ default_query={**self._default_query, **(default_query or {})},
216
+ http_client=self._http,
217
+ )
218
+ return client
219
+
220
+
221
+ class AsyncOpencode(AsyncAPIClient, AsyncResources):
222
+ """Asynchronous client for an OpenCode server. Works with asyncio and trio.
223
+
224
+ Takes the same arguments as :class:`Opencode`; ``http_client`` is an ``httpx.AsyncClient``.
225
+
226
+ Example::
227
+
228
+ from opencode import AsyncOpencode
229
+
230
+ async with AsyncOpencode("http://127.0.0.1:4096", password="...") as client:
231
+ for agent in await client.agent.list():
232
+ print(agent.id)
233
+ """
234
+
235
+ def __init__(
236
+ self,
237
+ base_url: str | httpx.URL | None = None,
238
+ *,
239
+ password: str | None = None,
240
+ username: str = DEFAULT_USERNAME,
241
+ directory: str | os.PathLike[str] | None = None,
242
+ timeout: Timeout = DEFAULT_TIMEOUT,
243
+ max_retries: int = DEFAULT_MAX_RETRIES,
244
+ default_headers: Mapping[str, str] | None = None,
245
+ default_query: Mapping[str, object] | None = None,
246
+ http_client: httpx.AsyncClient | None = None,
247
+ transport: httpx.AsyncBaseTransport | None = None,
248
+ ) -> None:
249
+ super().__init__(
250
+ base_url=base_url,
251
+ password=password,
252
+ username=username,
253
+ directory=directory,
254
+ timeout=timeout,
255
+ max_retries=max_retries,
256
+ default_headers=default_headers,
257
+ default_query=default_query,
258
+ http_client=http_client,
259
+ transport=transport,
260
+ )
261
+
262
+ @classmethod
263
+ async def from_service(
264
+ cls,
265
+ *,
266
+ start: bool = True,
267
+ file: str | os.PathLike[str] | None = None,
268
+ version: Version | None = None,
269
+ command: Sequence[str] | None = None,
270
+ env: Mapping[str, str] | None = None,
271
+ on_start: Callable[[EnsureReason, str | None], None] | None = None,
272
+ directory: str | os.PathLike[str] | None = None,
273
+ timeout: Timeout = DEFAULT_TIMEOUT,
274
+ max_retries: int = DEFAULT_MAX_RETRIES,
275
+ default_headers: Mapping[str, str] | None = None,
276
+ default_query: Mapping[str, object] | None = None,
277
+ ) -> Self:
278
+ """Async version of :meth:`Opencode.from_service`. Await it: ``client = await AsyncOpencode.from_service()``."""
279
+ if start:
280
+ endpoint = await async_ensure(file, version, command=command, env=env, on_start=on_start)
281
+ else:
282
+ found = await async_discover(file, version)
283
+ if found is None:
284
+ raise ServiceError("No matching OpenCode background service is running")
285
+ endpoint = found
286
+ return cls(
287
+ endpoint.url,
288
+ # An empty password sends no credentials instead of falling back to OPENCODE_PASSWORD.
289
+ password="" if endpoint.auth is None else endpoint.auth.password,
290
+ username=DEFAULT_USERNAME if endpoint.auth is None else endpoint.auth.username,
291
+ directory=directory,
292
+ timeout=timeout,
293
+ max_retries=max_retries,
294
+ default_headers=default_headers,
295
+ default_query=default_query,
296
+ )
297
+
298
+ @cached_property
299
+ def events(self) -> AsyncEventHub:
300
+ """The shared event stream: one connection for every subscriber. See :class:`opencode.AsyncEventHub`."""
301
+ return AsyncEventHub(self)
302
+
303
+ @cached_property
304
+ def rpc(self) -> AsyncTypedRpcResource:
305
+ """Plugin RPC calls: ``client.rpc.call(...)``, or ``client.rpc(Definition)`` for typed access."""
306
+ return AsyncTypedRpcResource(self)
307
+
308
+ @classmethod
309
+ def fake(cls, server: FakeServer | None = None, **kwargs: Any) -> Self:
310
+ """Returns a client wired to an in-process :class:`opencode.testing.FakeServer`, for tests.
311
+
312
+ Pass your own ``server`` to configure replies and inspect requests; other arguments go to the constructor.
313
+ """
314
+ from .testing import FakeServer
315
+
316
+ kwargs.setdefault("password", "")
317
+ return cls(FAKE_BASE_URL, transport=server if server is not None else FakeServer(), **kwargs)
318
+
319
+ async def wait_until_ready(
320
+ self, *, agents: Iterable[str] = (), mcp_servers: Iterable[str] = (), timeout: float = 60
321
+ ) -> None:
322
+ """Blocks until the server answers, the named ``agents`` are listed and the named ``mcp_servers`` are
323
+ connected.
324
+
325
+ Agents load asynchronously after the server starts, so call this before creating sessions on a freshly
326
+ booted server. Connection errors and 503s while the server boots count as not ready yet.
327
+
328
+ Raises:
329
+ TimeoutError: Something was still missing after ``timeout`` seconds. The message names it.
330
+ """
331
+ from .lib._ready import async_wait_until_ready
332
+
333
+ await async_wait_until_ready(self, agents=agents, mcp_servers=mcp_servers, timeout=timeout)
334
+
335
+ def with_options(
336
+ self,
337
+ *,
338
+ base_url: str | httpx.URL | None = None,
339
+ password: str | NotGiven | None = NOT_GIVEN,
340
+ directory: str | os.PathLike[str] | NotGiven | None = NOT_GIVEN,
341
+ timeout: Timeout | NotGiven = NOT_GIVEN,
342
+ max_retries: int | None = None,
343
+ default_headers: Mapping[str, str] | None = None,
344
+ default_query: Mapping[str, object] | None = None,
345
+ ) -> Self:
346
+ """Returns a client with some options changed. It shares this client's connection pool.
347
+
348
+ ``default_headers`` and ``default_query`` are merged over the current values.
349
+ """
350
+ client = type(self)(
351
+ base_url=base_url or self.base_url,
352
+ password=self.password or "" if isinstance(password, NotGiven) else password,
353
+ username=self.username,
354
+ directory=self.directory if isinstance(directory, NotGiven) else directory,
355
+ timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
356
+ max_retries=self.max_retries if max_retries is None else max_retries,
357
+ default_headers={**self._default_headers, **(default_headers or {})},
358
+ default_query={**self._default_query, **(default_query or {})},
359
+ http_client=self._http,
360
+ )
361
+ return client