opencode-python-sdk 0.0.1.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. opencode/.generated.json +39 -0
  2. opencode/__init__.py +221 -0
  3. opencode/_base_client.py +539 -0
  4. opencode/_client.py +255 -0
  5. opencode/_constants.py +27 -0
  6. opencode/_errors.py +547 -0
  7. opencode/_events.py +732 -0
  8. opencode/_exceptions.py +271 -0
  9. opencode/_models.py +54 -0
  10. opencode/_pagination.py +192 -0
  11. opencode/_pty.py +586 -0
  12. opencode/_qs.py +95 -0
  13. opencode/_resource.py +42 -0
  14. opencode/_rpc.py +489 -0
  15. opencode/_server.py +335 -0
  16. opencode/_streaming.py +248 -0
  17. opencode/_types.py +77 -0
  18. opencode/_unions.py +46 -0
  19. opencode/_version.py +8 -0
  20. opencode/lib/__init__.py +5 -0
  21. opencode/lib/_ready.py +91 -0
  22. opencode/lib/_session.py +231 -0
  23. opencode/py.typed +0 -0
  24. opencode/resources/__init__.py +364 -0
  25. opencode/resources/agent.py +179 -0
  26. opencode/resources/command.py +100 -0
  27. opencode/resources/config.py +240 -0
  28. opencode/resources/credential.py +384 -0
  29. opencode/resources/debug.py +178 -0
  30. opencode/resources/event.py +88 -0
  31. opencode/resources/experimental.py +765 -0
  32. opencode/resources/file.py +355 -0
  33. opencode/resources/form.py +100 -0
  34. opencode/resources/generate.py +110 -0
  35. opencode/resources/integration.py +1033 -0
  36. opencode/resources/location.py +162 -0
  37. opencode/resources/mcp.py +492 -0
  38. opencode/resources/message.py +152 -0
  39. opencode/resources/migration.py +118 -0
  40. opencode/resources/model.py +177 -0
  41. opencode/resources/permission.py +704 -0
  42. opencode/resources/plugin.py +250 -0
  43. opencode/resources/project.py +187 -0
  44. opencode/resources/provider.py +183 -0
  45. opencode/resources/pty.py +522 -0
  46. opencode/resources/reference.py +100 -0
  47. opencode/resources/rpc.py +113 -0
  48. opencode/resources/server.py +230 -0
  49. opencode/resources/session.py +3567 -0
  50. opencode/resources/shell.py +412 -0
  51. opencode/resources/skill.py +100 -0
  52. opencode/resources/vcs.py +500 -0
  53. opencode/resources/voice.py +113 -0
  54. opencode/resources/websearch.py +184 -0
  55. opencode/resources/worktree.py +336 -0
  56. opencode/testing.py +508 -0
  57. opencode/types/__init__.py +1462 -0
  58. opencode/types/_generated.py +8540 -0
  59. opencode_python_sdk-0.0.1.dev1.dist-info/METADATA +468 -0
  60. opencode_python_sdk-0.0.1.dev1.dist-info/RECORD +62 -0
  61. opencode_python_sdk-0.0.1.dev1.dist-info/WHEEL +4 -0
  62. opencode_python_sdk-0.0.1.dev1.dist-info/licenses/LICENSE +21 -0
opencode/_server.py ADDED
@@ -0,0 +1,335 @@
1
+ """Starts and stops a local ``opencode serve`` process."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections
6
+ import contextlib
7
+ import json
8
+ import os
9
+ import queue
10
+ import secrets
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import threading
15
+ import time
16
+ from pathlib import Path
17
+ from typing import IO, TYPE_CHECKING, Any, cast
18
+
19
+ import anyio.to_thread
20
+ import httpx
21
+ from typing_extensions import Self
22
+
23
+ from ._client import AsyncOpencode, Opencode
24
+ from ._constants import DEFAULT_USERNAME
25
+ from ._exceptions import OpencodeError
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Mapping
29
+ from types import TracebackType
30
+
31
+ from ._types import Timeout
32
+
33
+ __all__ = ["AsyncOpencodeServer", "OpencodeServer", "ServerStartError"]
34
+
35
+ _ISOLATION_ENV = {
36
+ # Ignore the working directory's opencode.json, AGENTS.md and .opencode folder.
37
+ "OPENCODE_CONFIG_PROJECT_DISABLE": "1",
38
+ # Ignore Claude Code skills and instructions found in the project or home directory.
39
+ "OPENCODE_DISABLE_CLAUDE_CODE": "1",
40
+ }
41
+
42
+
43
+ class ServerStartError(OpencodeError):
44
+ """``opencode serve`` could not be started or did not report its address in time."""
45
+
46
+ def __init__(self, message: str, *, output: str = "") -> None:
47
+ super().__init__(f"{message}\n\nServer output:\n{output}" if output else message)
48
+ self.output = output
49
+
50
+
51
+ class OpencodeServer:
52
+ """Runs ``opencode serve`` as a child process for the lifetime of a ``with`` block.
53
+
54
+ The server listens on a free local port and requires a random password, which is passed to clients created
55
+ with :meth:`client`. It runs in ``--stdio`` mode, so it also exits if this process dies.
56
+
57
+ Args:
58
+ cwd: Directory the server runs in, which is its default project location.
59
+ config: OpenCode configuration as a mapping, passed through ``OPENCODE_CONFIG_CONTENT``.
60
+ isolated: Ignore project and Claude Code configuration found on disk, for reproducible embedded use.
61
+ env: Extra environment variables for the server.
62
+ hostname: Interface to bind. Keep ``127.0.0.1`` unless the server must be reachable from other hosts.
63
+ port: Port to bind. ``0`` picks a free port.
64
+ password: Server password. A random one is generated by default.
65
+ binary: The ``opencode`` executable, looked up on ``PATH`` by default.
66
+ startup_timeout: Seconds to wait for the server to report its address.
67
+
68
+ Example::
69
+
70
+ with OpencodeServer(cwd="/path/to/repo", isolated=True) as server:
71
+ client = server.client()
72
+ print(client.server.info().version)
73
+ """
74
+
75
+ def __init__(
76
+ self,
77
+ *,
78
+ cwd: str | os.PathLike[str] | None = None,
79
+ config: Mapping[str, Any] | None = None,
80
+ isolated: bool = False,
81
+ env: Mapping[str, str] | None = None,
82
+ hostname: str = "127.0.0.1",
83
+ port: int = 0,
84
+ password: str | None = None,
85
+ binary: str | os.PathLike[str] = "opencode",
86
+ startup_timeout: float = 60.0,
87
+ ) -> None:
88
+ self.cwd = Path(cwd) if cwd is not None else None
89
+ self.config = dict(config) if config is not None else None
90
+ self.isolated = isolated
91
+ self.env = dict(env or {})
92
+ self.hostname = hostname
93
+ self.port = port
94
+ self.password = password if password is not None else secrets.token_urlsafe(32)
95
+ self.binary = os.fspath(binary)
96
+ self.startup_timeout = startup_timeout
97
+ self._process: subprocess.Popen[str] | None = None
98
+ self._url: str | None = None
99
+ self._output: collections.deque[str] = collections.deque(maxlen=200)
100
+ self._lock = threading.Lock()
101
+ self._threads: list[threading.Thread] = []
102
+
103
+ @property
104
+ def url(self) -> str:
105
+ """The server's base URL. Available once started."""
106
+ if self._url is None:
107
+ raise OpencodeError("The OpenCode server is not running. Call start() or use it as a context manager.")
108
+ return self._url
109
+
110
+ @property
111
+ def process(self) -> subprocess.Popen[str] | None:
112
+ """The ``opencode serve`` child process, or ``None`` before it starts."""
113
+ return self._process
114
+
115
+ @property
116
+ def running(self) -> bool:
117
+ """Whether the server process is alive."""
118
+ return self._process is not None and self._process.poll() is None
119
+
120
+ def client(self, *, directory: str | os.PathLike[str] | None = None, timeout: Timeout | None = None) -> Opencode:
121
+ """Returns a sync client connected to this server."""
122
+ kwargs: dict[str, Any] = {} if timeout is None else {"timeout": timeout}
123
+ return Opencode(self.url, password=self.password, username=DEFAULT_USERNAME, directory=directory, **kwargs)
124
+
125
+ def async_client(
126
+ self, *, directory: str | os.PathLike[str] | None = None, timeout: Timeout | None = None
127
+ ) -> AsyncOpencode:
128
+ """Returns an async client connected to this server."""
129
+ kwargs: dict[str, Any] = {} if timeout is None else {"timeout": timeout}
130
+ return AsyncOpencode(self.url, password=self.password, username=DEFAULT_USERNAME, directory=directory, **kwargs)
131
+
132
+ def _command(self) -> list[str]:
133
+ executable = shutil.which(self.binary)
134
+ if executable is None:
135
+ raise ServerStartError(
136
+ f"Could not find the {self.binary!r} executable. Install OpenCode (https://opencode.ai) "
137
+ "or pass binary= with its path."
138
+ )
139
+ return [executable, "serve", "--stdio", "--hostname", self.hostname, "--port", str(self.port)]
140
+
141
+ def _environment(self) -> dict[str, str]:
142
+ environment = dict(os.environ)
143
+ # The server reads its password from the environment and removes it from what its tools inherit.
144
+ environment.pop("OPENCODE_SERVER_PASSWORD", None)
145
+ environment["OPENCODE_PASSWORD"] = self.password
146
+ if self.isolated:
147
+ environment.update(_ISOLATION_ENV)
148
+ if self.config is not None:
149
+ environment["OPENCODE_CONFIG_CONTENT"] = json.dumps(self.config)
150
+ environment.update(self.env)
151
+ return environment
152
+
153
+ def start(self) -> Self:
154
+ """Starts the server and waits until it reports its address."""
155
+ with self._lock:
156
+ if self.running:
157
+ return self
158
+ self._output.clear()
159
+ command = self._command()
160
+ try:
161
+ process = subprocess.Popen(
162
+ command,
163
+ cwd=self.cwd,
164
+ env=self._environment(),
165
+ stdin=subprocess.PIPE,
166
+ stdout=subprocess.PIPE,
167
+ stderr=subprocess.PIPE,
168
+ text=True,
169
+ encoding="utf-8",
170
+ errors="replace",
171
+ bufsize=1,
172
+ # Keep Ctrl+C in a terminal from reaching the server before we can shut it down cleanly.
173
+ start_new_session=sys.platform != "win32",
174
+ )
175
+ except OSError as exc:
176
+ raise ServerStartError(f"Could not start {command[0]}: {exc}") from exc
177
+ self._process = process
178
+ lines: queue.Queue[str | None] = queue.Queue()
179
+ assert process.stdout is not None
180
+ assert process.stderr is not None
181
+ self._pump(process.stdout, lines)
182
+ self._pump(process.stderr, None)
183
+
184
+ url = self._wait_for_url(process, lines)
185
+ if url is None:
186
+ output = "\n".join(self._output)
187
+ self._shutdown(process)
188
+ self._process = None
189
+ raise ServerStartError("OpenCode server did not start", output=output)
190
+ self._url = url
191
+ return self
192
+
193
+ def _pump(self, stream: IO[str], lines: queue.Queue[str | None] | None) -> None:
194
+ def run() -> None:
195
+ try:
196
+ for line in stream:
197
+ stripped = line.rstrip("\n")
198
+ self._output.append(stripped)
199
+ if lines is not None:
200
+ lines.put(stripped)
201
+ except (OSError, ValueError):
202
+ pass
203
+ finally:
204
+ if lines is not None:
205
+ lines.put(None)
206
+
207
+ thread = threading.Thread(target=run, name="opencode-server-output", daemon=True)
208
+ thread.start()
209
+ self._threads.append(thread)
210
+
211
+ def _wait_for_url(self, process: subprocess.Popen[str], lines: queue.Queue[str | None]) -> str | None:
212
+ deadline = time.monotonic() + self.startup_timeout
213
+ while (remaining := deadline - time.monotonic()) > 0:
214
+ try:
215
+ line = lines.get(timeout=min(remaining, 0.5))
216
+ except queue.Empty:
217
+ if process.poll() is not None:
218
+ return None
219
+ continue
220
+ if line is None:
221
+ return None
222
+ try:
223
+ message = json.loads(line)
224
+ except ValueError:
225
+ continue
226
+ url: object = cast("dict[str, Any]", message).get("url") if isinstance(message, dict) else None
227
+ if isinstance(url, str):
228
+ return str(httpx.URL(url))
229
+ return None
230
+
231
+ def stop(self, timeout: float = 10.0) -> None:
232
+ """Stops the server: closes its stdin, then terminates it if it has not exited within ``timeout``."""
233
+ with self._lock:
234
+ process = self._process
235
+ self._process = None
236
+ self._url = None
237
+ if process is not None:
238
+ self._shutdown(process, timeout)
239
+
240
+ def _shutdown(self, process: subprocess.Popen[str], timeout: float = 10.0) -> None:
241
+ self._terminate(process, timeout)
242
+ # The output threads finish at end of file; then the pipes can be closed.
243
+ for thread in self._threads:
244
+ thread.join(timeout=2)
245
+ self._threads.clear()
246
+ for stream in (process.stdout, process.stderr):
247
+ if stream is not None:
248
+ with contextlib.suppress(OSError):
249
+ stream.close()
250
+
251
+ @staticmethod
252
+ def _terminate(process: subprocess.Popen[str], timeout: float = 10.0) -> None:
253
+ if process.stdin is not None:
254
+ with contextlib.suppress(OSError):
255
+ process.stdin.close()
256
+ try:
257
+ process.wait(timeout=timeout)
258
+ return
259
+ except subprocess.TimeoutExpired:
260
+ process.terminate()
261
+ try:
262
+ process.wait(timeout=5)
263
+ except subprocess.TimeoutExpired:
264
+ process.kill()
265
+ process.wait()
266
+
267
+ @property
268
+ def output(self) -> str:
269
+ """The last lines the server wrote, for diagnostics."""
270
+ return "\n".join(self._output)
271
+
272
+ def __enter__(self) -> Self:
273
+ return self.start()
274
+
275
+ def __exit__(
276
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
277
+ ) -> None:
278
+ self.stop()
279
+
280
+
281
+ class AsyncOpencodeServer:
282
+ """Async wrapper around :class:`OpencodeServer`; takes the same arguments.
283
+
284
+ Example::
285
+
286
+ async with AsyncOpencodeServer(isolated=True) as server:
287
+ async with server.client() as client:
288
+ info = await client.server.info()
289
+ """
290
+
291
+ def __init__(self, **kwargs: Any) -> None:
292
+ self._server = OpencodeServer(**kwargs)
293
+
294
+ @property
295
+ def url(self) -> str:
296
+ """The server's base URL. Available once started."""
297
+ return self._server.url
298
+
299
+ @property
300
+ def password(self) -> str:
301
+ """The server password clients authenticate with."""
302
+ return self._server.password
303
+
304
+ @property
305
+ def running(self) -> bool:
306
+ """Whether the server process is alive."""
307
+ return self._server.running
308
+
309
+ @property
310
+ def output(self) -> str:
311
+ """The last lines the server wrote, for diagnostics."""
312
+ return self._server.output
313
+
314
+ def client(
315
+ self, *, directory: str | os.PathLike[str] | None = None, timeout: Timeout | None = None
316
+ ) -> AsyncOpencode:
317
+ """Returns an async client connected to this server."""
318
+ return self._server.async_client(directory=directory, timeout=timeout)
319
+
320
+ async def start(self) -> Self:
321
+ """Starts the server and waits until it reports its address."""
322
+ await anyio.to_thread.run_sync(self._server.start)
323
+ return self
324
+
325
+ async def stop(self, timeout: float = 10.0) -> None:
326
+ """Stops the server: closes its stdin, then terminates it if it has not exited within ``timeout``."""
327
+ await anyio.to_thread.run_sync(lambda: self._server.stop(timeout))
328
+
329
+ async def __aenter__(self) -> Self:
330
+ return await self.start()
331
+
332
+ async def __aexit__(
333
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
334
+ ) -> None:
335
+ await self.stop()
opencode/_streaming.py ADDED
@@ -0,0 +1,248 @@
1
+ """Server-sent event streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import re
8
+ import socket
9
+ from dataclasses import dataclass, field
10
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
11
+
12
+ import httpx
13
+ import pydantic
14
+ from typing_extensions import Self
15
+
16
+ from ._exceptions import APIConnectionError, APIResponseValidationError, validation_summary
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable, Generator, Iterator
20
+ from types import TracebackType
21
+
22
+ __all__ = ["AsyncStream", "ServerSentEvent", "Stream"]
23
+
24
+ _T = TypeVar("_T")
25
+
26
+ MAX_EVENT_BYTES = 16 * 1024 * 1024
27
+ """Upper bound for one buffered event, matching the official TypeScript client."""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ServerSentEvent:
32
+ """One decoded server-sent event."""
33
+
34
+ data: str
35
+ event: str | None = None
36
+ id: str | None = None
37
+ retry: int | None = None
38
+
39
+
40
+ @dataclass
41
+ class _Decoder:
42
+ """Incremental SSE decoder following the WHATWG event-stream parsing rules."""
43
+
44
+ _data: list[str] = field(default_factory=list[str])
45
+ _event: str | None = None
46
+ _id: str | None = None
47
+ _retry: int | None = None
48
+ _size: int = 0
49
+
50
+ def feed_line(self, line: str) -> ServerSentEvent | None:
51
+ if line == "":
52
+ return self._dispatch()
53
+ if line.startswith(":"):
54
+ return None
55
+ name, _, value = line.partition(":")
56
+ value = value.removeprefix(" ")
57
+ if name == "data":
58
+ self._size += len(value)
59
+ if self._size > MAX_EVENT_BYTES:
60
+ raise ValueError(f"Server-sent event exceeds {MAX_EVENT_BYTES} bytes")
61
+ self._data.append(value)
62
+ elif name == "event":
63
+ self._event = value
64
+ elif name == "id":
65
+ if "\0" not in value:
66
+ self._id = value
67
+ elif name == "retry" and value.isdigit():
68
+ self._retry = int(value)
69
+ return None
70
+
71
+ def _dispatch(self) -> ServerSentEvent | None:
72
+ if not self._data:
73
+ self._event = None
74
+ return None
75
+ event = ServerSentEvent(data="\n".join(self._data), event=self._event, id=self._id, retry=self._retry)
76
+ self._data = []
77
+ self._event = None
78
+ self._retry = None
79
+ self._size = 0
80
+ return event
81
+
82
+
83
+ _NEWLINE = re.compile(r"\r\n|\r|\n")
84
+
85
+
86
+ class _LineSplitter:
87
+ """Splits decoded text into lines on CRLF, CR or LF, across chunk boundaries."""
88
+
89
+ def __init__(self) -> None:
90
+ self._buffer = ""
91
+
92
+ def feed(self, chunk: str) -> list[str]:
93
+ buffer = self._buffer + chunk
94
+ lines: list[str] = []
95
+ start = 0
96
+ while (match := _NEWLINE.search(buffer, start)) is not None:
97
+ if match.group() == "\r" and match.end() == len(buffer):
98
+ break # may be the first half of a CRLF split across chunks
99
+ lines.append(buffer[start : match.start()])
100
+ start = match.end()
101
+ self._buffer = buffer[start:]
102
+ if len(self._buffer) > MAX_EVENT_BYTES:
103
+ raise ValueError(f"Server-sent event line exceeds {MAX_EVENT_BYTES} bytes")
104
+ return lines
105
+
106
+ def flush(self) -> list[str]:
107
+ """Returns the final partial line, then an empty line so a pending event is dispatched."""
108
+ rest, self._buffer = self._buffer.removesuffix("\r"), ""
109
+ return [rest, ""] if rest else [""]
110
+
111
+
112
+ class _StreamBase(Generic[_T]):
113
+ def __init__(self, response: httpx.Response, adapter: pydantic.TypeAdapter[_T]) -> None:
114
+ self.response = response
115
+ self._adapter = adapter
116
+ self.on_activity: Callable[[], object] | None = None
117
+ """Called for every chunk the stream receives, including keepalive comments that carry no event."""
118
+
119
+ def _activity(self) -> None:
120
+ if self.on_activity is not None:
121
+ self.on_activity()
122
+
123
+ def _decode(self, event: ServerSentEvent) -> _T:
124
+ try:
125
+ payload: Any = json.loads(event.data)
126
+ except json.JSONDecodeError as exc:
127
+ raise APIResponseValidationError(self.response, event.data, message="Malformed server-sent event") from exc
128
+ try:
129
+ return self._adapter.validate_python(payload)
130
+ except pydantic.ValidationError as exc:
131
+ raise APIResponseValidationError(self.response, payload, message=validation_summary(exc)) from exc
132
+
133
+
134
+ class Stream(_StreamBase[_T]):
135
+ """A synchronous stream of decoded events.
136
+
137
+ Iterate it to receive events. Close it, or use it as a context manager, to release the connection::
138
+
139
+ with client.event.subscribe() as events:
140
+ for event in events:
141
+ ...
142
+ """
143
+
144
+ def __init__(self, response: httpx.Response, adapter: pydantic.TypeAdapter[_T]) -> None:
145
+ super().__init__(response, adapter)
146
+ self._iterator: Generator[_T, None, None] = self._events()
147
+
148
+ def __iter__(self) -> Iterator[_T]:
149
+ return self._iterator
150
+
151
+ def __next__(self) -> _T:
152
+ return next(self._iterator)
153
+
154
+ def _events(self) -> Generator[_T, None, None]:
155
+ decoder = _Decoder()
156
+ try:
157
+ splitter = _LineSplitter()
158
+ for chunk in self.response.iter_text():
159
+ self._activity()
160
+ for line in splitter.feed(chunk):
161
+ if (event := decoder.feed_line(line)) is not None:
162
+ yield self._decode(event)
163
+ for line in splitter.flush():
164
+ if (event := decoder.feed_line(line)) is not None:
165
+ yield self._decode(event)
166
+ except (httpx.TransportError, ValueError) as exc:
167
+ if isinstance(exc, ValueError):
168
+ raise APIResponseValidationError(self.response, None, message=str(exc)) from exc
169
+ raise APIConnectionError(request=self.response.request, message=f"Stream interrupted: {exc}") from exc
170
+ finally:
171
+ self.response.close()
172
+
173
+ def close(self) -> None:
174
+ """Closes the underlying connection."""
175
+ self._iterator.close()
176
+ self.response.close()
177
+
178
+ def interrupt(self) -> None:
179
+ """Unblocks a read in progress on another thread, which then ends with :class:`APIConnectionError`.
180
+
181
+ ``close`` must run on the iterating thread. Call this from any other thread first to wake it.
182
+ """
183
+ network_stream: Any = self.response.extensions.get("network_stream")
184
+ sock: Any = network_stream.get_extra_info("socket") if network_stream is not None else None
185
+ if isinstance(sock, socket.socket):
186
+ with contextlib.suppress(OSError):
187
+ sock.shutdown(socket.SHUT_RDWR)
188
+
189
+ def __enter__(self) -> Self:
190
+ return self
191
+
192
+ def __exit__(
193
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
194
+ ) -> None:
195
+ self.close()
196
+
197
+
198
+ class AsyncStream(_StreamBase[_T]):
199
+ """An asynchronous stream of decoded events.
200
+
201
+ Iterate it with ``async for``. Close it, or use it as an async context manager, to release the connection::
202
+
203
+ async with await client.event.subscribe() as events:
204
+ async for event in events:
205
+ ...
206
+ """
207
+
208
+ def __init__(self, response: httpx.Response, adapter: pydantic.TypeAdapter[_T]) -> None:
209
+ super().__init__(response, adapter)
210
+ self._iterator: AsyncGenerator[_T, None] = self._events()
211
+
212
+ def __aiter__(self) -> AsyncIterator[_T]:
213
+ return self._iterator
214
+
215
+ async def __anext__(self) -> _T:
216
+ return await self._iterator.__anext__()
217
+
218
+ async def _events(self) -> AsyncGenerator[_T, None]:
219
+ decoder = _Decoder()
220
+ try:
221
+ splitter = _LineSplitter()
222
+ async for chunk in self.response.aiter_text():
223
+ self._activity()
224
+ for line in splitter.feed(chunk):
225
+ if (event := decoder.feed_line(line)) is not None:
226
+ yield self._decode(event)
227
+ for line in splitter.flush():
228
+ if (event := decoder.feed_line(line)) is not None:
229
+ yield self._decode(event)
230
+ except (httpx.TransportError, ValueError) as exc:
231
+ if isinstance(exc, ValueError):
232
+ raise APIResponseValidationError(self.response, None, message=str(exc)) from exc
233
+ raise APIConnectionError(request=self.response.request, message=f"Stream interrupted: {exc}") from exc
234
+ finally:
235
+ await self.response.aclose()
236
+
237
+ async def close(self) -> None:
238
+ """Closes the underlying connection."""
239
+ await self._iterator.aclose()
240
+ await self.response.aclose()
241
+
242
+ async def __aenter__(self) -> Self:
243
+ return self
244
+
245
+ async def __aexit__(
246
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
247
+ ) -> None:
248
+ await self.close()
opencode/_types.py ADDED
@@ -0,0 +1,77 @@
1
+ """Shared typing helpers used by the client and the generated code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Annotated, Any, Final, Literal, TypeAlias, TypeVar
7
+
8
+ import httpx
9
+ import pydantic
10
+ from typing_extensions import override
11
+
12
+ __all__ = [
13
+ "NOT_GIVEN",
14
+ "Body",
15
+ "Headers",
16
+ "NotGiven",
17
+ "OpenEnum",
18
+ "Query",
19
+ "Timeout",
20
+ ]
21
+
22
+ _T = TypeVar("_T")
23
+
24
+
25
+ class NotGiven:
26
+ """Marks an argument that was not passed, as opposed to one passed as ``None``.
27
+
28
+ The OpenCode API distinguishes a field that is absent from a field set to ``null``, so optional
29
+ arguments default to ``NOT_GIVEN`` and are left out of the request entirely.
30
+ """
31
+
32
+ _instance: NotGiven | None = None
33
+
34
+ def __new__(cls) -> NotGiven:
35
+ if cls._instance is None:
36
+ cls._instance = super().__new__(cls)
37
+ return cls._instance
38
+
39
+ def __bool__(self) -> Literal[False]:
40
+ return False
41
+
42
+ @override
43
+ def __repr__(self) -> str:
44
+ return "NOT_GIVEN"
45
+
46
+ @override
47
+ def __reduce__(self) -> str:
48
+ return "NOT_GIVEN"
49
+
50
+
51
+ NOT_GIVEN: Final = NotGiven()
52
+
53
+ Headers: TypeAlias = Mapping[str, str | NotGiven]
54
+ """Extra request headers. A ``NOT_GIVEN`` value removes a header the client would otherwise send."""
55
+
56
+ Query: TypeAlias = Mapping[str, object]
57
+ """Extra query parameters, encoded the same way as generated parameters."""
58
+
59
+ Body: TypeAlias = Mapping[str, object]
60
+ """Extra JSON body fields, merged over the generated body."""
61
+
62
+ Timeout: TypeAlias = float | httpx.Timeout | None
63
+ """A timeout in seconds, an ``httpx.Timeout``, or ``None`` to wait forever."""
64
+
65
+
66
+ def _accept_unknown_value(value: Any, handler: pydantic.ValidatorFunctionWrapHandler) -> Any:
67
+ try:
68
+ return handler(value)
69
+ except pydantic.ValidationError:
70
+ # Newer servers may add enum members. Keep the raw value instead of failing the whole response.
71
+ if isinstance(value, (str, int, float, bool)):
72
+ return value
73
+ raise
74
+
75
+
76
+ OpenEnum = Annotated[_T, pydantic.WrapValidator(_accept_unknown_value)]
77
+ """A ``Literal`` enum that type-checks strictly but accepts values added by newer servers at runtime."""