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.
- opencode_python_sdk-0.2.0/CHANGELOG.md +18 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/PKG-INFO +126 -1
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/README.md +125 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/__init__.py +62 -1
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_base_client.py +24 -3
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_client.py +90 -2
- opencode_python_sdk-0.2.0/src/opencode/_events.py +732 -0
- opencode_python_sdk-0.2.0/src/opencode/_rpc.py +489 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_streaming.py +22 -1
- 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.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/agent.py +13 -11
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/command.py +9 -7
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/config.py +12 -10
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/credential.py +13 -11
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/debug.py +9 -7
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/event.py +9 -5
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/experimental.py +31 -29
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/file.py +15 -13
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/form.py +9 -7
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/generate.py +9 -7
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/integration.py +28 -26
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/location.py +9 -7
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/mcp.py +13 -11
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/message.py +9 -8
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/migration.py +10 -12
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/model.py +12 -10
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/permission.py +29 -27
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/plugin.py +12 -10
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/project.py +14 -12
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/provider.py +13 -11
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/pty.py +23 -21
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/reference.py +9 -7
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/rpc.py +9 -7
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/server.py +14 -12
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/session.py +92 -90
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/shell.py +19 -17
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/skill.py +9 -7
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/vcs.py +22 -20
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/voice.py +9 -7
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/websearch.py +13 -11
- {opencode_python_sdk-0.1.2 → 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_events.py +396 -0
- opencode_python_sdk-0.2.0/tests/test_helpers.py +252 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/test_live.py +71 -2
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/test_models.py +9 -0
- opencode_python_sdk-0.2.0/tests/test_rpc.py +154 -0
- opencode_python_sdk-0.1.2/CHANGELOG.md +0 -9
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/.gitignore +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/LICENSE +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/api.md +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/pyproject.toml +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/.generated.json +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_constants.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_errors.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_exceptions.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_models.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_pagination.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_pty.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_qs.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_resource.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_server.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_types.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_unions.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/_version.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/py.typed +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/resources/__init__.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/types/__init__.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/src/opencode/types/_generated.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/__init__.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/conftest.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/test_async_client.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/test_client.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/test_docs_coverage.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/test_encoding.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/test_pagination.py +0 -0
- {opencode_python_sdk-0.1.2 → opencode_python_sdk-0.2.0}/tests/test_pty.py +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
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,22 +1,30 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
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
|
*,
|