opencode-python-sdk 0.1.2__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.
Files changed (80) hide show
  1. opencode_python_sdk-0.2.0/CHANGELOG.md +18 -0
  2. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/PKG-INFO +126 -1
  3. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/README.md +125 -0
  4. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/__init__.py +62 -1
  5. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_base_client.py +24 -3
  6. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_client.py +90 -2
  7. opencode_python_sdk-0.2.0/src/opencode/_events.py +732 -0
  8. opencode_python_sdk-0.2.0/src/opencode/_rpc.py +489 -0
  9. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_streaming.py +22 -1
  10. opencode_python_sdk-0.2.0/src/opencode/lib/__init__.py +5 -0
  11. opencode_python_sdk-0.2.0/src/opencode/lib/_ready.py +91 -0
  12. opencode_python_sdk-0.2.0/src/opencode/lib/_session.py +231 -0
  13. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/agent.py +13 -11
  14. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/command.py +9 -7
  15. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/config.py +12 -10
  16. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/credential.py +13 -11
  17. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/debug.py +9 -7
  18. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/event.py +9 -5
  19. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/experimental.py +31 -29
  20. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/file.py +15 -13
  21. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/form.py +9 -7
  22. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/generate.py +9 -7
  23. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/integration.py +28 -26
  24. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/location.py +9 -7
  25. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/mcp.py +13 -11
  26. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/message.py +9 -8
  27. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/migration.py +10 -12
  28. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/model.py +12 -10
  29. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/permission.py +29 -27
  30. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/plugin.py +12 -10
  31. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/project.py +14 -12
  32. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/provider.py +13 -11
  33. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/pty.py +23 -21
  34. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/reference.py +9 -7
  35. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/rpc.py +9 -7
  36. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/server.py +14 -12
  37. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/session.py +92 -90
  38. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/shell.py +19 -17
  39. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/skill.py +9 -7
  40. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/vcs.py +22 -20
  41. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/voice.py +9 -7
  42. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/websearch.py +13 -11
  43. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/worktree.py +13 -11
  44. opencode_python_sdk-0.2.0/src/opencode/testing.py +508 -0
  45. opencode_python_sdk-0.2.0/tests/test_events.py +396 -0
  46. opencode_python_sdk-0.2.0/tests/test_helpers.py +252 -0
  47. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/test_live.py +71 -2
  48. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/test_models.py +9 -0
  49. opencode_python_sdk-0.2.0/tests/test_rpc.py +154 -0
  50. opencode_python_sdk-0.1.2/CHANGELOG.md +0 -9
  51. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/.gitignore +0 -0
  52. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/LICENSE +0 -0
  53. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/api.md +0 -0
  54. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/pyproject.toml +0 -0
  55. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/.generated.json +0 -0
  56. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_constants.py +0 -0
  57. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_errors.py +0 -0
  58. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_exceptions.py +0 -0
  59. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_models.py +0 -0
  60. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_pagination.py +0 -0
  61. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_pty.py +0 -0
  62. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_qs.py +0 -0
  63. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_resource.py +0 -0
  64. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_server.py +0 -0
  65. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_types.py +0 -0
  66. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_unions.py +0 -0
  67. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_version.py +0 -0
  68. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/py.typed +0 -0
  69. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/__init__.py +0 -0
  70. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/types/__init__.py +0 -0
  71. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/types/_generated.py +0 -0
  72. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/__init__.py +0 -0
  73. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/conftest.py +0 -0
  74. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/test_async_client.py +0 -0
  75. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/test_client.py +0 -0
  76. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/test_docs_coverage.py +0 -0
  77. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/test_encoding.py +0 -0
  78. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/test_pagination.py +0 -0
  79. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/test_pty.py +0 -0
  80. {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/test_streaming.py +0 -0
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ Releases are published automatically on every merge to `main`. Release notes for each version are on the
4
+ [GitHub releases page](https://github.com/shrijayan/opencode-python-sdk/releases).
5
+
6
+ ## Unreleased
7
+
8
+ - `client.events`: a shared event stream with one connection for all subscribers, bounded per-subscriber queues,
9
+ `server.connected` replay, `of_type` / `for_session` / `where` filters, `on_activity` keepalive hook, cancellation,
10
+ and opt-in reconnection with backoff (`EventHub(client, reconnect=True)`).
11
+ - `client.rpc(Definition)`: typed plugin RPC calls and event subscriptions declared with `RpcDefinition`,
12
+ `rpc_method` and `rpc_event`; RPC failures raise `RpcCallError` / `RpcCallInternalError`.
13
+ - `Stream.on_activity` and `Stream.interrupt()`.
14
+
15
+ ## 0.1.0
16
+
17
+ First release, generated from OpenCode 2.1.2: sync and async clients for all 142 operations, typed models and
18
+ events, typed errors, pagination, event streams, retries, and the `OpencodeServer` launcher.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opencode-python-sdk
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Typed Python client for the OpenCode 2.x server API
5
5
  Project-URL: Homepage, https://github.com/shrijayan/opencode-python-sdk
6
6
  Project-URL: Documentation, https://shrijayan.github.io/opencode-python-sdk/
@@ -206,6 +206,82 @@ Events are typed (`SessionCreatedEvent`, `SessionTextDeltaEvent`, and so on). Ev
206
206
  not know arrive as `UnknownEvent` with all their fields kept. Streams apply only the connect timeout by default, since
207
207
  they stay open between events.
208
208
 
209
+ ### Shared event stream
210
+
211
+ `client.events` shares one connection among every subscriber, like `client.event` in the TypeScript client. The
212
+ connection opens with the first subscriber and closes when the last one leaves. Each subscriber has its own queue of
213
+ up to 4096 events; one that falls further behind fails with `EventOverflowError` without affecting the others. The
214
+ latest `server.connected` event is replayed to subscribers that join later.
215
+
216
+ ```python
217
+ from opencode.types import SessionIdleEvent
218
+
219
+ with client.events.of_type(SessionIdleEvent).for_session(session.id) as idle:
220
+ client.session.prompt(session.id, text="Hello")
221
+ event = idle.get(timeout=300) # typed as SessionIdleEvent
222
+ ```
223
+
224
+ - Filters chain: `of_type(*classes)` narrows the yielded type, `for_session(id)` keeps one session's events, and
225
+ `where(predicate)` takes any test. Entering the `with` block joins the stream, so nothing published after that is
226
+ missed.
227
+ - `subscribe(on_activity=callback)` calls `callback` for every chunk received, keepalive comments included, as a
228
+ liveness signal.
229
+ - `close()` (or leaving the `with` block) ends the subscription, also from another thread. In async code, cancel a wait
230
+ with `anyio.fail_after`, `asyncio.timeout` or a trio cancel scope; the subscription stays usable.
231
+ - Reconnection is opt-in: `EventHub(client, reconnect=True)` (or `AsyncEventHub`, or a `Reconnect(...)` policy) reopens
232
+ the connection with exponential backoff after connection errors, 5xx and 429. Subscribers see `server.connected`
233
+ once. **The stream is volatile**: events published while disconnected are lost, so re-read state through the API
234
+ after a reconnect when it matters.
235
+
236
+ The async hub works the same way under asyncio and trio:
237
+
238
+ ```python
239
+ async with client.events.for_session(session.id) as events:
240
+ async for event in events:
241
+ print(event.type)
242
+ ```
243
+
244
+ `client.event.subscribe()` still opens a dedicated connection.
245
+
246
+ ### Plugin RPC
247
+
248
+ Declare a plugin RPC once as a class, then call it with typed inputs and outputs:
249
+
250
+ ```python
251
+ from pydantic import BaseModel
252
+ from opencode import RpcCallError, RpcDefinition, rpc_event, rpc_method
253
+
254
+
255
+ class AddInput(BaseModel):
256
+ amount: int
257
+
258
+
259
+ class Total(BaseModel):
260
+ total: int
261
+
262
+
263
+ class Counter(RpcDefinition, id="counter"):
264
+ add = rpc_method(AddInput, Total)
265
+ reset = rpc_method(None, Total)
266
+ changed = rpc_event(Total)
267
+
268
+
269
+ counter = client.rpc(Counter)
270
+ try:
271
+ print(counter.call(Counter.add, AddInput(amount=2)).total)
272
+ except RpcCallError as error:
273
+ print(error.type, error.message, error.data)
274
+
275
+ with counter.subscribe(Counter.changed) as changes: # only rpc.counter.changed, with validated data
276
+ for change in changes:
277
+ print(change.data.total)
278
+ ```
279
+
280
+ Any type pydantic validates works for inputs, outputs and event data. `rpc_method(..., errors={"not_found": Model})`
281
+ validates the `data` of declared errors. Failures raise `RpcCallError`; crashes and outputs that do not match the
282
+ declared type raise `RpcCallInternalError`. `counter.events()` yields every raw event of the RPC. The untyped
283
+ `client.rpc.call(rpc_id, method, input=...)` is still available.
284
+
209
285
  ## Terminals
210
286
 
211
287
  `client.pty.attach()` opens an interactive connection to a PTY over a WebSocket. It needs the `pty` extra:
@@ -274,6 +350,55 @@ client.session.get("ses_...", timeout=5.0) # per call
274
350
 
275
351
  The default timeout is 10 minutes with a 10 second connect timeout.
276
352
 
353
+ ## Running an agent headless
354
+
355
+ `client.session.run` does the usual "prompt an agent and wait" round trip in one call: it creates a session (or
356
+ reuses `session_id`), sends the prompt, blocks until the session is idle, and returns the new messages with the
357
+ final text and usage. `client.wait_until_ready` waits until agents and MCP servers have loaded, which happens
358
+ asynchronously after the server boots. Both have async versions on `AsyncOpencode`.
359
+
360
+ ```python
361
+ from opencode import Opencode
362
+ from opencode.types import ModelRef, PromptInputFileAttachment
363
+
364
+ with Opencode("http://127.0.0.1:4096", password="...") as client:
365
+ client.wait_until_ready(agents=["reviewer"], mcp_servers=["github"], timeout=60)
366
+ result = client.session.run(
367
+ "Review the attached diff",
368
+ agent="reviewer",
369
+ model=ModelRef(provider_id="anthropic", id="claude-sonnet-5-5"), # set it with the agent
370
+ files=[PromptInputFileAttachment(uri="data:text/plain;base64,...", name="diff.patch")],
371
+ timeout=600,
372
+ )
373
+ print(result.outcome, result.text)
374
+ print(result.cost, result.tokens.input, result.tokens.output) # this run; result.session has the totals
375
+ ```
376
+
377
+ If the wait ends early (Ctrl-C, task cancellation, `timeout`, a connection error), `run` interrupts the session
378
+ on the server before re-raising, so the agent does not keep running unattended.
379
+
380
+ ## Testing your code
381
+
382
+ `opencode.testing.FakeServer` is an in-process fake of the server: an `httpx` transport that keeps sessions,
383
+ messages and events in memory and answers with the same payloads as the real server. No process, no model.
384
+
385
+ ```python
386
+ from opencode import Opencode
387
+ from opencode.testing import FakeServer
388
+
389
+
390
+ def test_summarize() -> None:
391
+ fake = FakeServer(reply=lambda prompt: f"summary of {prompt!r}")
392
+ client = Opencode.fake(fake) # same as Opencode(transport=fake)
393
+
394
+ assert client.session.run("README").text == "summary of 'README'"
395
+ assert fake.prompts == ["README"]
396
+ ```
397
+
398
+ `fake.fail(method, path_regex, status=..., exception=...)` injects errors, `fake.add_agent(..., after_polls=n)`
399
+ and `fake.set_mcp_status(...)` simulate slow boots, and `fake.requests` records every request. Routes the fake
400
+ does not implement answer `404`.
401
+
277
402
  ## Running a server
278
403
 
279
404
  `OpencodeServer` runs `opencode serve` as a child process on a free local port with a random password:
@@ -169,6 +169,82 @@ Events are typed (`SessionCreatedEvent`, `SessionTextDeltaEvent`, and so on). Ev
169
169
  not know arrive as `UnknownEvent` with all their fields kept. Streams apply only the connect timeout by default, since
170
170
  they stay open between events.
171
171
 
172
+ ### Shared event stream
173
+
174
+ `client.events` shares one connection among every subscriber, like `client.event` in the TypeScript client. The
175
+ connection opens with the first subscriber and closes when the last one leaves. Each subscriber has its own queue of
176
+ up to 4096 events; one that falls further behind fails with `EventOverflowError` without affecting the others. The
177
+ latest `server.connected` event is replayed to subscribers that join later.
178
+
179
+ ```python
180
+ from opencode.types import SessionIdleEvent
181
+
182
+ with client.events.of_type(SessionIdleEvent).for_session(session.id) as idle:
183
+ client.session.prompt(session.id, text="Hello")
184
+ event = idle.get(timeout=300) # typed as SessionIdleEvent
185
+ ```
186
+
187
+ - Filters chain: `of_type(*classes)` narrows the yielded type, `for_session(id)` keeps one session's events, and
188
+ `where(predicate)` takes any test. Entering the `with` block joins the stream, so nothing published after that is
189
+ missed.
190
+ - `subscribe(on_activity=callback)` calls `callback` for every chunk received, keepalive comments included, as a
191
+ liveness signal.
192
+ - `close()` (or leaving the `with` block) ends the subscription, also from another thread. In async code, cancel a wait
193
+ with `anyio.fail_after`, `asyncio.timeout` or a trio cancel scope; the subscription stays usable.
194
+ - Reconnection is opt-in: `EventHub(client, reconnect=True)` (or `AsyncEventHub`, or a `Reconnect(...)` policy) reopens
195
+ the connection with exponential backoff after connection errors, 5xx and 429. Subscribers see `server.connected`
196
+ once. **The stream is volatile**: events published while disconnected are lost, so re-read state through the API
197
+ after a reconnect when it matters.
198
+
199
+ The async hub works the same way under asyncio and trio:
200
+
201
+ ```python
202
+ async with client.events.for_session(session.id) as events:
203
+ async for event in events:
204
+ print(event.type)
205
+ ```
206
+
207
+ `client.event.subscribe()` still opens a dedicated connection.
208
+
209
+ ### Plugin RPC
210
+
211
+ Declare a plugin RPC once as a class, then call it with typed inputs and outputs:
212
+
213
+ ```python
214
+ from pydantic import BaseModel
215
+ from opencode import RpcCallError, RpcDefinition, rpc_event, rpc_method
216
+
217
+
218
+ class AddInput(BaseModel):
219
+ amount: int
220
+
221
+
222
+ class Total(BaseModel):
223
+ total: int
224
+
225
+
226
+ class Counter(RpcDefinition, id="counter"):
227
+ add = rpc_method(AddInput, Total)
228
+ reset = rpc_method(None, Total)
229
+ changed = rpc_event(Total)
230
+
231
+
232
+ counter = client.rpc(Counter)
233
+ try:
234
+ print(counter.call(Counter.add, AddInput(amount=2)).total)
235
+ except RpcCallError as error:
236
+ print(error.type, error.message, error.data)
237
+
238
+ with counter.subscribe(Counter.changed) as changes: # only rpc.counter.changed, with validated data
239
+ for change in changes:
240
+ print(change.data.total)
241
+ ```
242
+
243
+ Any type pydantic validates works for inputs, outputs and event data. `rpc_method(..., errors={"not_found": Model})`
244
+ validates the `data` of declared errors. Failures raise `RpcCallError`; crashes and outputs that do not match the
245
+ declared type raise `RpcCallInternalError`. `counter.events()` yields every raw event of the RPC. The untyped
246
+ `client.rpc.call(rpc_id, method, input=...)` is still available.
247
+
172
248
  ## Terminals
173
249
 
174
250
  `client.pty.attach()` opens an interactive connection to a PTY over a WebSocket. It needs the `pty` extra:
@@ -237,6 +313,55 @@ client.session.get("ses_...", timeout=5.0) # per call
237
313
 
238
314
  The default timeout is 10 minutes with a 10 second connect timeout.
239
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
+
240
365
  ## Running a server
241
366
 
242
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 . import types
17
+ from typing import TYPE_CHECKING, Any
18
+
18
19
  from ._client import AsyncOpencode, Opencode
19
20
 
20
21
  # <generated:error-imports>
@@ -81,6 +82,36 @@ from ._streaming import AsyncStream, Stream
81
82
  from ._types import NOT_GIVEN, NotGiven
82
83
  from ._version import __version__
83
84
 
85
+ # isort: split
86
+ # Hand-written helpers, kept apart from the generated error-import block above.
87
+ from ._events import (
88
+ SUBSCRIBER_CAPACITY,
89
+ AsyncEventHub,
90
+ AsyncSubscription,
91
+ EventHub,
92
+ EventOverflowError,
93
+ Reconnect,
94
+ Subscription,
95
+ session_id_of,
96
+ )
97
+ from ._rpc import (
98
+ AsyncRpcClient,
99
+ AsyncRpcEvents,
100
+ RpcCallError,
101
+ RpcCallInternalError,
102
+ RpcClient,
103
+ RpcDefinition,
104
+ RpcEventDefinition,
105
+ RpcEventPayload,
106
+ RpcEvents,
107
+ RpcMethod,
108
+ rpc_event,
109
+ rpc_method,
110
+ )
111
+
112
+ if TYPE_CHECKING:
113
+ from . import types
114
+
84
115
  # <generated:api-version>
85
116
  API_VERSION = "2.1.2"
86
117
  """The OpenCode server version this client was generated from."""
@@ -123,6 +154,27 @@ __all__ = [
123
154
  "UnprocessableEntityError",
124
155
  "__version__",
125
156
  "types",
157
+ # Shared event stream and typed plugin RPC
158
+ "SUBSCRIBER_CAPACITY",
159
+ "AsyncEventHub",
160
+ "AsyncRpcClient",
161
+ "AsyncRpcEvents",
162
+ "AsyncSubscription",
163
+ "EventHub",
164
+ "EventOverflowError",
165
+ "Reconnect",
166
+ "RpcCallError",
167
+ "RpcCallInternalError",
168
+ "RpcClient",
169
+ "RpcDefinition",
170
+ "RpcEventDefinition",
171
+ "RpcEventPayload",
172
+ "RpcEvents",
173
+ "RpcMethod",
174
+ "Subscription",
175
+ "rpc_event",
176
+ "rpc_method",
177
+ "session_id_of",
126
178
  # <generated:error-exports>
127
179
  "AgentNotFoundError",
128
180
  "CommandExecutionError",
@@ -158,3 +210,12 @@ __all__ = [
158
210
  "WorktreeError",
159
211
  # </generated:error-exports>
160
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
- 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."""
@@ -1,22 +1,30 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING
3
+ from functools import cached_property
4
+ from typing import TYPE_CHECKING, Any
4
5
 
5
6
  from typing_extensions import Self
6
7
 
7
8
  from ._base_client import AsyncAPIClient, SyncAPIClient
8
9
  from ._constants import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, DEFAULT_USERNAME
10
+ from ._events import AsyncEventHub, EventHub
11
+ from ._rpc import AsyncTypedRpcResource, TypedRpcResource
9
12
  from ._types import NOT_GIVEN, NotGiven, Timeout
10
13
  from .resources import AsyncResources, SyncResources
11
14
 
12
15
  if TYPE_CHECKING:
13
16
  import os
14
- from collections.abc import Mapping
17
+ from collections.abc import Iterable, Mapping
15
18
 
16
19
  import httpx
17
20
 
21
+ from .testing import FakeServer
22
+
18
23
  __all__ = ["AsyncOpencode", "Opencode"]
19
24
 
25
+ FAKE_BASE_URL = "http://opencode.fake"
26
+ """Base URL of clients made by ``Opencode.fake()``."""
27
+
20
28
 
21
29
  class Opencode(SyncAPIClient, SyncResources):
22
30
  """Synchronous client for an OpenCode server.
@@ -35,6 +43,8 @@ class Opencode(SyncAPIClient, SyncResources):
35
43
  default_query: Query parameters sent with every request.
36
44
  http_client: A preconfigured ``httpx.Client`` (proxies, custom transports, event hooks). The caller keeps
37
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``.
38
48
 
39
49
  Example::
40
50
 
@@ -57,6 +67,7 @@ class Opencode(SyncAPIClient, SyncResources):
57
67
  default_headers: Mapping[str, str] | None = None,
58
68
  default_query: Mapping[str, object] | None = None,
59
69
  http_client: httpx.Client | None = None,
70
+ transport: httpx.BaseTransport | None = None,
60
71
  ) -> None:
61
72
  super().__init__(
62
73
  base_url=base_url,
@@ -68,8 +79,46 @@ class Opencode(SyncAPIClient, SyncResources):
68
79
  default_headers=default_headers,
69
80
  default_query=default_query,
70
81
  http_client=http_client,
82
+ transport=transport,
71
83
  )
72
84
 
85
+ @cached_property
86
+ def events(self) -> EventHub:
87
+ """The shared event stream: one connection for every subscriber. See :class:`opencode.EventHub`."""
88
+ return EventHub(self)
89
+
90
+ @cached_property
91
+ def rpc(self) -> TypedRpcResource:
92
+ """Plugin RPC calls: ``client.rpc.call(...)``, or ``client.rpc(Definition)`` for typed access."""
93
+ return TypedRpcResource(self)
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
+
73
122
  def with_options(
74
123
  self,
75
124
  *,
@@ -125,6 +174,7 @@ class AsyncOpencode(AsyncAPIClient, AsyncResources):
125
174
  default_headers: Mapping[str, str] | None = None,
126
175
  default_query: Mapping[str, object] | None = None,
127
176
  http_client: httpx.AsyncClient | None = None,
177
+ transport: httpx.AsyncBaseTransport | None = None,
128
178
  ) -> None:
129
179
  super().__init__(
130
180
  base_url=base_url,
@@ -136,8 +186,46 @@ class AsyncOpencode(AsyncAPIClient, AsyncResources):
136
186
  default_headers=default_headers,
137
187
  default_query=default_query,
138
188
  http_client=http_client,
189
+ transport=transport,
139
190
  )
140
191
 
192
+ @cached_property
193
+ def events(self) -> AsyncEventHub:
194
+ """The shared event stream: one connection for every subscriber. See :class:`opencode.AsyncEventHub`."""
195
+ return AsyncEventHub(self)
196
+
197
+ @cached_property
198
+ def rpc(self) -> AsyncTypedRpcResource:
199
+ """Plugin RPC calls: ``client.rpc.call(...)``, or ``client.rpc(Definition)`` for typed access."""
200
+ return AsyncTypedRpcResource(self)
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
+
141
229
  def with_options(
142
230
  self,
143
231
  *,