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.
- opencode/.generated.json +39 -0
- opencode/__init__.py +156 -0
- opencode/_base_client.py +518 -0
- opencode/_client.py +167 -0
- opencode/_constants.py +27 -0
- opencode/_errors.py +547 -0
- opencode/_exceptions.py +249 -0
- opencode/_models.py +54 -0
- opencode/_pagination.py +192 -0
- opencode/_qs.py +95 -0
- opencode/_resource.py +42 -0
- opencode/_server.py +327 -0
- opencode/_streaming.py +227 -0
- opencode/_types.py +77 -0
- opencode/_unions.py +46 -0
- opencode/_version.py +8 -0
- opencode/py.typed +0 -0
- opencode/resources/__init__.py +364 -0
- opencode/resources/agent.py +177 -0
- opencode/resources/command.py +98 -0
- opencode/resources/config.py +238 -0
- opencode/resources/credential.py +382 -0
- opencode/resources/debug.py +174 -0
- opencode/resources/event.py +84 -0
- opencode/resources/experimental.py +760 -0
- opencode/resources/file.py +353 -0
- opencode/resources/form.py +98 -0
- opencode/resources/generate.py +108 -0
- opencode/resources/integration.py +1023 -0
- opencode/resources/location.py +160 -0
- opencode/resources/mcp.py +488 -0
- opencode/resources/message.py +151 -0
- opencode/resources/migration.py +118 -0
- opencode/resources/model.py +175 -0
- opencode/resources/permission.py +698 -0
- opencode/resources/plugin.py +248 -0
- opencode/resources/project.py +185 -0
- opencode/resources/provider.py +181 -0
- opencode/resources/pty.py +517 -0
- opencode/resources/reference.py +98 -0
- opencode/resources/rpc.py +111 -0
- opencode/resources/server.py +228 -0
- opencode/resources/session.py +3553 -0
- opencode/resources/shell.py +410 -0
- opencode/resources/skill.py +98 -0
- opencode/resources/vcs.py +496 -0
- opencode/resources/voice.py +111 -0
- opencode/resources/websearch.py +182 -0
- opencode/resources/worktree.py +334 -0
- opencode/types/__init__.py +1462 -0
- opencode/types/_generated.py +8540 -0
- opencode_python_sdk-0.1.0.dist-info/METADATA +317 -0
- opencode_python_sdk-0.1.0.dist-info/RECORD +55 -0
- opencode_python_sdk-0.1.0.dist-info/WHEEL +4 -0
- opencode_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
opencode/_base_client.py
ADDED
|
@@ -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
|