opencode-python-sdk 0.1.0__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 (55) hide show
  1. opencode/.generated.json +39 -0
  2. opencode/__init__.py +156 -0
  3. opencode/_base_client.py +518 -0
  4. opencode/_client.py +167 -0
  5. opencode/_constants.py +27 -0
  6. opencode/_errors.py +547 -0
  7. opencode/_exceptions.py +249 -0
  8. opencode/_models.py +54 -0
  9. opencode/_pagination.py +192 -0
  10. opencode/_qs.py +95 -0
  11. opencode/_resource.py +42 -0
  12. opencode/_server.py +327 -0
  13. opencode/_streaming.py +227 -0
  14. opencode/_types.py +77 -0
  15. opencode/_unions.py +46 -0
  16. opencode/_version.py +8 -0
  17. opencode/py.typed +0 -0
  18. opencode/resources/__init__.py +364 -0
  19. opencode/resources/agent.py +177 -0
  20. opencode/resources/command.py +98 -0
  21. opencode/resources/config.py +238 -0
  22. opencode/resources/credential.py +382 -0
  23. opencode/resources/debug.py +174 -0
  24. opencode/resources/event.py +84 -0
  25. opencode/resources/experimental.py +760 -0
  26. opencode/resources/file.py +353 -0
  27. opencode/resources/form.py +98 -0
  28. opencode/resources/generate.py +108 -0
  29. opencode/resources/integration.py +1023 -0
  30. opencode/resources/location.py +160 -0
  31. opencode/resources/mcp.py +488 -0
  32. opencode/resources/message.py +151 -0
  33. opencode/resources/migration.py +118 -0
  34. opencode/resources/model.py +175 -0
  35. opencode/resources/permission.py +698 -0
  36. opencode/resources/plugin.py +248 -0
  37. opencode/resources/project.py +185 -0
  38. opencode/resources/provider.py +181 -0
  39. opencode/resources/pty.py +517 -0
  40. opencode/resources/reference.py +98 -0
  41. opencode/resources/rpc.py +111 -0
  42. opencode/resources/server.py +228 -0
  43. opencode/resources/session.py +3553 -0
  44. opencode/resources/shell.py +410 -0
  45. opencode/resources/skill.py +98 -0
  46. opencode/resources/vcs.py +496 -0
  47. opencode/resources/voice.py +111 -0
  48. opencode/resources/websearch.py +182 -0
  49. opencode/resources/worktree.py +334 -0
  50. opencode/types/__init__.py +1462 -0
  51. opencode/types/_generated.py +8540 -0
  52. opencode_python_sdk-0.1.0.dist-info/METADATA +317 -0
  53. opencode_python_sdk-0.1.0.dist-info/RECORD +55 -0
  54. opencode_python_sdk-0.1.0.dist-info/WHEEL +4 -0
  55. opencode_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,518 @@
1
+ """HTTP transport shared by the sync and async clients.
2
+
3
+ This module is the only place that talks to httpx. It builds requests, applies authentication, retries
4
+ transient failures, and turns responses into typed values or errors.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import email.utils
11
+ import functools
12
+ import json
13
+ import logging
14
+ import os
15
+ import platform
16
+ import random
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast
20
+
21
+ import anyio
22
+ import httpx
23
+ import pydantic
24
+ from typing_extensions import Self, override
25
+
26
+ from . import _constants as constants
27
+ from ._errors import TAGGED_ERRORS
28
+ from ._exceptions import (
29
+ APIConnectionError,
30
+ APIResponseValidationError,
31
+ APIStatusError,
32
+ APITimeoutError,
33
+ OpencodeError,
34
+ error_message,
35
+ status_error_class,
36
+ validation_summary,
37
+ )
38
+ from ._qs import encode_query, to_jsonable
39
+ from ._streaming import AsyncStream, Stream
40
+ from ._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout
41
+ from ._version import __version__
42
+
43
+ if TYPE_CHECKING:
44
+ from collections.abc import Mapping
45
+ from types import TracebackType
46
+
47
+ from ._pagination import AsyncPaginator, SyncCursorPage
48
+
49
+ __all__ = ["AsyncAPIClient", "SyncAPIClient"]
50
+
51
+ log = logging.getLogger("opencode")
52
+
53
+ _T = TypeVar("_T")
54
+
55
+ ResponseKind = Literal["json", "void", "binary"]
56
+
57
+
58
+ @functools.cache
59
+ def _cached_adapter(tp: Any) -> pydantic.TypeAdapter[Any]:
60
+ return pydantic.TypeAdapter(tp)
61
+
62
+
63
+ def type_adapter(tp: Any) -> pydantic.TypeAdapter[Any]:
64
+ """Returns a cached ``TypeAdapter`` for a response type."""
65
+ try:
66
+ return _cached_adapter(tp)
67
+ except TypeError: # unhashable type expression
68
+ return pydantic.TypeAdapter(tp)
69
+
70
+
71
+ @dataclass
72
+ class RequestOptions:
73
+ """Everything needed to send one request. Generated methods build these."""
74
+
75
+ method: str
76
+ path: str
77
+ query: Mapping[str, object] = field(default_factory=dict[str, object])
78
+ headers: Mapping[str, object] = field(default_factory=dict[str, object])
79
+ body: object | NotGiven = NOT_GIVEN
80
+ content: bytes | None = None
81
+ extra_headers: Headers | None = None
82
+ extra_query: Query | None = None
83
+ extra_body: Body | None = None
84
+ timeout: Timeout | NotGiven = NOT_GIVEN
85
+ stream: bool = False
86
+
87
+ @property
88
+ def idempotent(self) -> bool:
89
+ return self.method.upper() in constants.IDEMPOTENT_METHODS
90
+
91
+
92
+ def _platform_headers() -> dict[str, str]:
93
+ return {
94
+ "User-Agent": f"opencode-python/{__version__} python/{platform.python_version()} httpx/{httpx.__version__}",
95
+ }
96
+
97
+
98
+ def _resolve_password(password: str | None) -> str | None:
99
+ if password is not None:
100
+ return password or None
101
+ for name in constants.PASSWORD_ENVS:
102
+ value = os.environ.get(name)
103
+ if value:
104
+ return value
105
+ return None
106
+
107
+
108
+ def _resolve_base_url(base_url: str | httpx.URL | None) -> httpx.URL:
109
+ if base_url is None:
110
+ base_url = os.environ.get(constants.BASE_URL_ENV)
111
+ if not base_url:
112
+ raise OpencodeError(
113
+ "No OpenCode server URL. Pass base_url=..., set the OPENCODE_BASE_URL environment variable, "
114
+ "or start a server with opencode.OpencodeServer()."
115
+ )
116
+ url = httpx.URL(str(base_url))
117
+ if url.scheme not in ("http", "https"):
118
+ raise OpencodeError(f"OpenCode base_url must be an http(s) URL, got {str(base_url)!r}")
119
+ # A path prefix on the base URL is kept: requests are joined relative to it.
120
+ return url if url.raw_path.endswith(b"/") else url.copy_with(raw_path=url.raw_path + b"/")
121
+
122
+
123
+ class _BaseClient:
124
+ """Configuration and request building shared by both clients."""
125
+
126
+ base_url: httpx.URL
127
+ username: str
128
+ password: str | None
129
+ timeout: Timeout
130
+ max_retries: int
131
+ directory: str | None
132
+
133
+ def __init__(
134
+ self,
135
+ *,
136
+ base_url: str | httpx.URL | None,
137
+ password: str | None,
138
+ username: str,
139
+ directory: str | os.PathLike[str] | None,
140
+ timeout: Timeout,
141
+ max_retries: int,
142
+ default_headers: Mapping[str, str] | None,
143
+ default_query: Mapping[str, object] | None,
144
+ ) -> None:
145
+ if max_retries < 0:
146
+ raise ValueError("max_retries must be zero or more")
147
+ self.base_url = _resolve_base_url(base_url)
148
+ self.username = username
149
+ self.password = _resolve_password(password)
150
+ self._authorization = (
151
+ None
152
+ if self.password is None
153
+ else "Basic " + base64.b64encode(f"{username}:{self.password}".encode()).decode("ascii")
154
+ )
155
+ self.directory = os.fspath(directory) if directory is not None else None
156
+ self.timeout = timeout
157
+ self.max_retries = max_retries
158
+ self._default_headers = dict(default_headers or {})
159
+ self._default_query = dict(default_query or {})
160
+
161
+ @override
162
+ def __repr__(self) -> str:
163
+ # Never include the password.
164
+ return f"{type(self).__name__}(base_url={str(self.base_url)!r})"
165
+
166
+ def _build_request(self, options: RequestOptions) -> httpx.Request:
167
+ url = self.base_url.join(options.path.lstrip("/"))
168
+ query = {**self._default_query, **options.query, **(options.extra_query or {})}
169
+
170
+ headers: dict[str, str] = {"Accept": "application/json", **_platform_headers(), **self._default_headers}
171
+ for name, value in {**options.headers, **(options.extra_headers or {})}.items():
172
+ if isinstance(value, NotGiven):
173
+ headers.pop(name, None)
174
+ elif value is not None:
175
+ headers[name] = str(value)
176
+ if options.stream:
177
+ headers["Accept"] = "text/event-stream"
178
+ if self._authorization is not None and "Authorization" not in headers:
179
+ headers["Authorization"] = self._authorization
180
+
181
+ content: bytes | None = options.content
182
+ if content is not None:
183
+ headers.setdefault("Content-Type", "application/octet-stream")
184
+ elif not isinstance(options.body, NotGiven) or options.extra_body:
185
+ body: Any = to_jsonable(options.body) if not isinstance(options.body, NotGiven) else {}
186
+ if options.extra_body:
187
+ if not isinstance(body, dict):
188
+ raise TypeError("extra_body can only be used with JSON object bodies")
189
+ extra: dict[str, Any] = to_jsonable(options.extra_body)
190
+ body = {**cast("dict[str, Any]", body), **extra}
191
+ content = _json_bytes(body)
192
+ headers["Content-Type"] = "application/json"
193
+
194
+ timeout = self.timeout if isinstance(options.timeout, NotGiven) else options.timeout
195
+ if options.stream and isinstance(options.timeout, NotGiven):
196
+ # Event streams stay open between events, so only bound the connect phase by default.
197
+ base = timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(timeout)
198
+ timeout = httpx.Timeout(connect=base.connect, read=None, write=base.write, pool=base.pool)
199
+ return httpx.Request(
200
+ options.method,
201
+ url,
202
+ params=httpx.QueryParams(cast("list[tuple[str, str | int | float | bool | None]]", encode_query(query))),
203
+ headers=headers,
204
+ content=content,
205
+ extensions={"timeout": _timeout_dict(timeout)},
206
+ )
207
+
208
+ def _status_error(self, response: httpx.Response, raw: object) -> APIStatusError:
209
+ body: object = raw
210
+ fields = cast("dict[str, Any]", raw) if isinstance(raw, dict) else {}
211
+ tag: object = fields.get("_tag")
212
+ cls = status_error_class(response.status_code, tag if isinstance(tag, str) else None, TAGGED_ERRORS)
213
+ return cls(error_message(body, response.status_code), response=response, body=body)
214
+
215
+ def _should_retry_status(self, options: RequestOptions, response: httpx.Response) -> bool:
216
+ statuses = constants.RETRYABLE_STATUSES if options.idempotent else constants.RETRYABLE_STATUSES_UNSAFE
217
+ return response.status_code in statuses
218
+
219
+ @staticmethod
220
+ def _should_retry_error(options: RequestOptions, error: httpx.TransportError) -> bool:
221
+ # These fail before the request reaches the server, so retrying never repeats work.
222
+ if isinstance(error, (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)):
223
+ return True
224
+ return options.idempotent
225
+
226
+ @staticmethod
227
+ def _retry_delay(attempt: int, response: httpx.Response | None) -> float:
228
+ if response is not None:
229
+ retry_after = _parse_retry_after(response.headers)
230
+ if retry_after is not None and 0 <= retry_after <= constants.MAX_RETRY_AFTER:
231
+ return retry_after
232
+ delay: float = min(constants.INITIAL_RETRY_DELAY * 2.0**attempt, constants.MAX_RETRY_DELAY)
233
+ return delay * (1 - 0.25 * random.random())
234
+
235
+ @staticmethod
236
+ def _decode_error_body(response: httpx.Response) -> object:
237
+ if not response.content:
238
+ return None
239
+ try:
240
+ return response.json()
241
+ except ValueError:
242
+ return response.text
243
+
244
+ def _parse(self, response: httpx.Response, kind: ResponseKind, response_type: Any, unwrap: bool) -> Any:
245
+ if kind == "void":
246
+ return None
247
+ if kind == "binary":
248
+ return response.content
249
+ try:
250
+ payload: Any = response.json()
251
+ except ValueError as exc:
252
+ raise APIResponseValidationError(response, response.text, message="Response is not valid JSON") from exc
253
+ if unwrap:
254
+ if not isinstance(payload, dict) or "data" not in payload:
255
+ raise APIResponseValidationError(
256
+ response, cast("object", payload), message="Response has no `data` field"
257
+ )
258
+ payload = cast("dict[str, Any]", payload)["data"]
259
+ try:
260
+ return type_adapter(response_type).validate_python(payload)
261
+ except pydantic.ValidationError as exc:
262
+ raise APIResponseValidationError(response, payload, message=validation_summary(exc)) from exc
263
+
264
+
265
+ def _json_bytes(body: object) -> bytes:
266
+ return json.dumps(body, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
267
+
268
+
269
+ def _timeout_dict(timeout: Timeout) -> dict[str, float | None]:
270
+ resolved = timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(timeout)
271
+ return resolved.as_dict()
272
+
273
+
274
+ def _parse_retry_after(headers: httpx.Headers) -> float | None:
275
+ milliseconds = headers.get("retry-after-ms")
276
+ if milliseconds is not None:
277
+ try:
278
+ return float(milliseconds) / 1000
279
+ except ValueError:
280
+ pass
281
+ value = headers.get("retry-after")
282
+ if value is None:
283
+ return None
284
+ try:
285
+ return float(value)
286
+ except ValueError:
287
+ parsed = email.utils.parsedate_to_datetime(value) if value else None
288
+ if parsed is None:
289
+ return None
290
+ return max(parsed.timestamp() - time.time(), 0.0)
291
+
292
+
293
+ class SyncAPIClient(_BaseClient):
294
+ """Synchronous transport. Thread-safe: share one client across threads."""
295
+
296
+ _http: httpx.Client
297
+
298
+ def __init__(
299
+ self,
300
+ *,
301
+ base_url: str | httpx.URL | None,
302
+ password: str | None,
303
+ username: str,
304
+ directory: str | os.PathLike[str] | None,
305
+ timeout: Timeout,
306
+ max_retries: int,
307
+ default_headers: Mapping[str, str] | None,
308
+ default_query: Mapping[str, object] | None,
309
+ http_client: httpx.Client | None,
310
+ ) -> None:
311
+ super().__init__(
312
+ base_url=base_url,
313
+ password=password,
314
+ username=username,
315
+ directory=directory,
316
+ timeout=timeout,
317
+ max_retries=max_retries,
318
+ default_headers=default_headers,
319
+ default_query=default_query,
320
+ )
321
+ self._owns_http = http_client is None
322
+ self._http = http_client or httpx.Client(limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True)
323
+
324
+ def close(self) -> None:
325
+ """Closes the connection pool, unless it was passed in by the caller."""
326
+ if self._owns_http:
327
+ self._http.close()
328
+
329
+ @property
330
+ def is_closed(self) -> bool:
331
+ return self._http.is_closed
332
+
333
+ def __enter__(self) -> Self:
334
+ return self
335
+
336
+ def __exit__(
337
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
338
+ ) -> None:
339
+ self.close()
340
+
341
+ def _send(self, options: RequestOptions) -> httpx.Response:
342
+ attempt = 0
343
+ while True:
344
+ request = self._build_request(options)
345
+ log.debug("Request %s %s (attempt %d)", request.method, request.url, attempt + 1)
346
+ try:
347
+ response = self._http.send(request, stream=options.stream)
348
+ except httpx.TransportError as exc:
349
+ if attempt < self.max_retries and self._should_retry_error(options, exc):
350
+ delay = self._retry_delay(attempt, None)
351
+ log.info("Retrying %s %s in %.2fs after %s", request.method, request.url, delay, type(exc).__name__)
352
+ time.sleep(delay)
353
+ attempt += 1
354
+ continue
355
+ if isinstance(exc, httpx.TimeoutException):
356
+ raise APITimeoutError(request) from exc
357
+ raise APIConnectionError(request=request, message=f"Connection error: {exc}") from exc
358
+
359
+ log.debug("Response %s %s -> %d", request.method, request.url, response.status_code)
360
+ if response.is_success:
361
+ return response
362
+ if attempt < self.max_retries and self._should_retry_status(options, response):
363
+ delay = self._retry_delay(attempt, response)
364
+ response.close()
365
+ log.info(
366
+ "Retrying %s %s in %.2fs after status %d", request.method, request.url, delay, response.status_code
367
+ )
368
+ time.sleep(delay)
369
+ attempt += 1
370
+ continue
371
+ if options.stream:
372
+ response.read()
373
+ raise self._status_error(response, self._decode_error_body(response))
374
+
375
+ def request(
376
+ self,
377
+ options: RequestOptions,
378
+ *,
379
+ kind: ResponseKind = "json",
380
+ response_type: Any = object,
381
+ unwrap: bool = False,
382
+ ) -> Any:
383
+ """Sends a request and returns the decoded response."""
384
+ response = self._send(options)
385
+ return self._parse(response, kind, response_type, unwrap)
386
+
387
+ def stream(self, options: RequestOptions, *, item_type: type[_T] | Any) -> Stream[Any]:
388
+ """Opens a server-sent event stream."""
389
+ options.stream = True
390
+ response = self._send(options)
391
+ content_type = response.headers.get("content-type", "")
392
+ if content_type.split(";", 1)[0].strip().lower() != "text/event-stream":
393
+ response.read()
394
+ response.close()
395
+ raise APIResponseValidationError(
396
+ response, response.text, message=f"Expected an event stream, got {content_type!r}"
397
+ )
398
+ return Stream(response, type_adapter(item_type))
399
+
400
+ def page(self, options: RequestOptions, *, response_type: Any) -> SyncCursorPage[Any]:
401
+ """Fetches the first page of a cursor-paginated list."""
402
+ from ._pagination import SyncCursorPage
403
+
404
+ return SyncCursorPage.fetch(self, options, response_type)
405
+
406
+
407
+ class AsyncAPIClient(_BaseClient):
408
+ """Asynchronous transport. Works with asyncio and trio."""
409
+
410
+ _http: httpx.AsyncClient
411
+
412
+ def __init__(
413
+ self,
414
+ *,
415
+ base_url: str | httpx.URL | None,
416
+ password: str | None,
417
+ username: str,
418
+ directory: str | os.PathLike[str] | None,
419
+ timeout: Timeout,
420
+ max_retries: int,
421
+ default_headers: Mapping[str, str] | None,
422
+ default_query: Mapping[str, object] | None,
423
+ http_client: httpx.AsyncClient | None,
424
+ ) -> None:
425
+ super().__init__(
426
+ base_url=base_url,
427
+ password=password,
428
+ username=username,
429
+ directory=directory,
430
+ timeout=timeout,
431
+ max_retries=max_retries,
432
+ default_headers=default_headers,
433
+ default_query=default_query,
434
+ )
435
+ self._owns_http = http_client is None
436
+ self._http = http_client or httpx.AsyncClient(limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True)
437
+
438
+ async def close(self) -> None:
439
+ """Closes the connection pool, unless it was passed in by the caller."""
440
+ if self._owns_http:
441
+ await self._http.aclose()
442
+
443
+ @property
444
+ def is_closed(self) -> bool:
445
+ return self._http.is_closed
446
+
447
+ async def __aenter__(self) -> Self:
448
+ return self
449
+
450
+ async def __aexit__(
451
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
452
+ ) -> None:
453
+ await self.close()
454
+
455
+ async def _send(self, options: RequestOptions) -> httpx.Response:
456
+ attempt = 0
457
+ while True:
458
+ request = self._build_request(options)
459
+ log.debug("Request %s %s (attempt %d)", request.method, request.url, attempt + 1)
460
+ try:
461
+ response = await self._http.send(request, stream=options.stream)
462
+ except httpx.TransportError as exc:
463
+ if attempt < self.max_retries and self._should_retry_error(options, exc):
464
+ delay = self._retry_delay(attempt, None)
465
+ log.info("Retrying %s %s in %.2fs after %s", request.method, request.url, delay, type(exc).__name__)
466
+ await anyio.sleep(delay)
467
+ attempt += 1
468
+ continue
469
+ if isinstance(exc, httpx.TimeoutException):
470
+ raise APITimeoutError(request) from exc
471
+ raise APIConnectionError(request=request, message=f"Connection error: {exc}") from exc
472
+
473
+ log.debug("Response %s %s -> %d", request.method, request.url, response.status_code)
474
+ if response.is_success:
475
+ return response
476
+ if attempt < self.max_retries and self._should_retry_status(options, response):
477
+ delay = self._retry_delay(attempt, response)
478
+ await response.aclose()
479
+ log.info(
480
+ "Retrying %s %s in %.2fs after status %d", request.method, request.url, delay, response.status_code
481
+ )
482
+ await anyio.sleep(delay)
483
+ attempt += 1
484
+ continue
485
+ if options.stream:
486
+ await response.aread()
487
+ raise self._status_error(response, self._decode_error_body(response))
488
+
489
+ async def request(
490
+ self,
491
+ options: RequestOptions,
492
+ *,
493
+ kind: ResponseKind = "json",
494
+ response_type: Any = object,
495
+ unwrap: bool = False,
496
+ ) -> Any:
497
+ """Sends a request and returns the decoded response."""
498
+ response = await self._send(options)
499
+ return self._parse(response, kind, response_type, unwrap)
500
+
501
+ async def stream(self, options: RequestOptions, *, item_type: type[_T] | Any) -> AsyncStream[Any]:
502
+ """Opens a server-sent event stream."""
503
+ options.stream = True
504
+ response = await self._send(options)
505
+ content_type = response.headers.get("content-type", "")
506
+ if content_type.split(";", 1)[0].strip().lower() != "text/event-stream":
507
+ await response.aread()
508
+ await response.aclose()
509
+ raise APIResponseValidationError(
510
+ response, response.text, message=f"Expected an event stream, got {content_type!r}"
511
+ )
512
+ return AsyncStream(response, type_adapter(item_type))
513
+
514
+ def page(self, options: RequestOptions, *, response_type: Any) -> AsyncPaginator[Any]:
515
+ """Returns an awaitable, async-iterable paginator over a cursor-paginated list."""
516
+ from ._pagination import AsyncPaginator
517
+
518
+ return AsyncPaginator(self, options, response_type)
opencode/_client.py ADDED
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from typing_extensions import Self
6
+
7
+ from ._base_client import AsyncAPIClient, SyncAPIClient
8
+ from ._constants import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, DEFAULT_USERNAME
9
+ from ._types import NOT_GIVEN, NotGiven, Timeout
10
+ from .resources import AsyncResources, SyncResources
11
+
12
+ if TYPE_CHECKING:
13
+ import os
14
+ from collections.abc import Mapping
15
+
16
+ import httpx
17
+
18
+ __all__ = ["AsyncOpencode", "Opencode"]
19
+
20
+
21
+ class Opencode(SyncAPIClient, SyncResources):
22
+ """Synchronous client for an OpenCode server.
23
+
24
+ Args:
25
+ base_url: Server URL, for example ``http://127.0.0.1:4096``. Defaults to ``OPENCODE_BASE_URL``.
26
+ password: Server password. Defaults to ``OPENCODE_PASSWORD``, then ``OPENCODE_SERVER_PASSWORD``. Pass ``""``
27
+ to send no credentials.
28
+ username: Basic auth user name. The OpenCode server uses ``opencode``.
29
+ directory: Default project directory. Sent as the ``location`` of every request that accepts one, unless
30
+ the call passes its own ``location``.
31
+ timeout: Default timeout in seconds, or an ``httpx.Timeout``. Event streams only apply the connect timeout.
32
+ max_retries: Retries for connection failures and transient statuses. Requests that may have changed state
33
+ on the server are only retried when the server refused them before acting (429 and 503).
34
+ default_headers: Headers sent with every request.
35
+ default_query: Query parameters sent with every request.
36
+ http_client: A preconfigured ``httpx.Client`` (proxies, custom transports, event hooks). The caller keeps
37
+ ownership and must close it.
38
+
39
+ Example::
40
+
41
+ from opencode import Opencode
42
+
43
+ with Opencode("http://127.0.0.1:4096", password="...") as client:
44
+ for agent in client.agent.list():
45
+ print(agent.id)
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ base_url: str | httpx.URL | None = None,
51
+ *,
52
+ password: str | None = None,
53
+ username: str = DEFAULT_USERNAME,
54
+ directory: str | os.PathLike[str] | None = None,
55
+ timeout: Timeout = DEFAULT_TIMEOUT,
56
+ max_retries: int = DEFAULT_MAX_RETRIES,
57
+ default_headers: Mapping[str, str] | None = None,
58
+ default_query: Mapping[str, object] | None = None,
59
+ http_client: httpx.Client | None = None,
60
+ ) -> None:
61
+ super().__init__(
62
+ base_url=base_url,
63
+ password=password,
64
+ username=username,
65
+ directory=directory,
66
+ timeout=timeout,
67
+ max_retries=max_retries,
68
+ default_headers=default_headers,
69
+ default_query=default_query,
70
+ http_client=http_client,
71
+ )
72
+
73
+ def with_options(
74
+ self,
75
+ *,
76
+ base_url: str | httpx.URL | None = None,
77
+ password: str | NotGiven | None = NOT_GIVEN,
78
+ directory: str | os.PathLike[str] | NotGiven | None = NOT_GIVEN,
79
+ timeout: Timeout | NotGiven = NOT_GIVEN,
80
+ max_retries: int | None = None,
81
+ default_headers: Mapping[str, str] | None = None,
82
+ default_query: Mapping[str, object] | None = None,
83
+ ) -> Self:
84
+ """Returns a client with some options changed. It shares this client's connection pool.
85
+
86
+ ``default_headers`` and ``default_query`` are merged over the current values.
87
+ """
88
+ client = type(self)(
89
+ base_url=base_url or self.base_url,
90
+ password=self.password or "" if isinstance(password, NotGiven) else password,
91
+ username=self.username,
92
+ directory=self.directory if isinstance(directory, NotGiven) else directory,
93
+ timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
94
+ max_retries=self.max_retries if max_retries is None else max_retries,
95
+ default_headers={**self._default_headers, **(default_headers or {})},
96
+ default_query={**self._default_query, **(default_query or {})},
97
+ http_client=self._http,
98
+ )
99
+ return client
100
+
101
+
102
+ class AsyncOpencode(AsyncAPIClient, AsyncResources):
103
+ """Asynchronous client for an OpenCode server. Works with asyncio and trio.
104
+
105
+ Takes the same arguments as :class:`Opencode`; ``http_client`` is an ``httpx.AsyncClient``.
106
+
107
+ Example::
108
+
109
+ from opencode import AsyncOpencode
110
+
111
+ async with AsyncOpencode("http://127.0.0.1:4096", password="...") as client:
112
+ for agent in await client.agent.list():
113
+ print(agent.id)
114
+ """
115
+
116
+ def __init__(
117
+ self,
118
+ base_url: str | httpx.URL | None = None,
119
+ *,
120
+ password: str | None = None,
121
+ username: str = DEFAULT_USERNAME,
122
+ directory: str | os.PathLike[str] | None = None,
123
+ timeout: Timeout = DEFAULT_TIMEOUT,
124
+ max_retries: int = DEFAULT_MAX_RETRIES,
125
+ default_headers: Mapping[str, str] | None = None,
126
+ default_query: Mapping[str, object] | None = None,
127
+ http_client: httpx.AsyncClient | None = None,
128
+ ) -> None:
129
+ super().__init__(
130
+ base_url=base_url,
131
+ password=password,
132
+ username=username,
133
+ directory=directory,
134
+ timeout=timeout,
135
+ max_retries=max_retries,
136
+ default_headers=default_headers,
137
+ default_query=default_query,
138
+ http_client=http_client,
139
+ )
140
+
141
+ def with_options(
142
+ self,
143
+ *,
144
+ base_url: str | httpx.URL | None = None,
145
+ password: str | NotGiven | None = NOT_GIVEN,
146
+ directory: str | os.PathLike[str] | NotGiven | None = NOT_GIVEN,
147
+ timeout: Timeout | NotGiven = NOT_GIVEN,
148
+ max_retries: int | None = None,
149
+ default_headers: Mapping[str, str] | None = None,
150
+ default_query: Mapping[str, object] | None = None,
151
+ ) -> Self:
152
+ """Returns a client with some options changed. It shares this client's connection pool.
153
+
154
+ ``default_headers`` and ``default_query`` are merged over the current values.
155
+ """
156
+ client = type(self)(
157
+ base_url=base_url or self.base_url,
158
+ password=self.password or "" if isinstance(password, NotGiven) else password,
159
+ username=self.username,
160
+ directory=self.directory if isinstance(directory, NotGiven) else directory,
161
+ timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
162
+ max_retries=self.max_retries if max_retries is None else max_retries,
163
+ default_headers={**self._default_headers, **(default_headers or {})},
164
+ default_query={**self._default_query, **(default_query or {})},
165
+ http_client=self._http,
166
+ )
167
+ return client