morphsdk 0.2.5__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 (61) hide show
  1. morphsdk/__init__.py +54 -0
  2. morphsdk/_agent/__init__.py +64 -0
  3. morphsdk/_agent/config.py +52 -0
  4. morphsdk/_agent/explore.py +276 -0
  5. morphsdk/_agent/github.py +57 -0
  6. morphsdk/_agent/helpers.py +133 -0
  7. morphsdk/_agent/parser.py +163 -0
  8. morphsdk/_agent/runner.py +524 -0
  9. morphsdk/_agent/tools.py +171 -0
  10. morphsdk/_agent/types.py +126 -0
  11. morphsdk/_base.py +309 -0
  12. morphsdk/_client.py +245 -0
  13. morphsdk/_config.py +37 -0
  14. morphsdk/_constants.py +53 -0
  15. morphsdk/_errors.py +111 -0
  16. morphsdk/_providers/__init__.py +36 -0
  17. morphsdk/_providers/_filter.py +92 -0
  18. morphsdk/_providers/base.py +94 -0
  19. morphsdk/_providers/code_storage_http.py +104 -0
  20. morphsdk/_providers/local.py +270 -0
  21. morphsdk/_providers/remote.py +161 -0
  22. morphsdk/_version.py +1 -0
  23. morphsdk/adapters/__init__.py +1 -0
  24. morphsdk/adapters/anthropic.py +360 -0
  25. morphsdk/adapters/langchain.py +120 -0
  26. morphsdk/adapters/openai.py +500 -0
  27. morphsdk/py.typed +0 -0
  28. morphsdk/resources/__init__.py +0 -0
  29. morphsdk/resources/browser.py +919 -0
  30. morphsdk/resources/compact.py +133 -0
  31. morphsdk/resources/edit.py +506 -0
  32. morphsdk/resources/explore.py +333 -0
  33. morphsdk/resources/git.py +861 -0
  34. morphsdk/resources/github.py +1214 -0
  35. morphsdk/resources/grep.py +583 -0
  36. morphsdk/resources/mobile.py +134 -0
  37. morphsdk/resources/reflex.py +414 -0
  38. morphsdk/resources/router.py +124 -0
  39. morphsdk/resources/search.py +110 -0
  40. morphsdk/tracing/__init__.py +70 -0
  41. morphsdk/tracing/_otel.py +101 -0
  42. morphsdk/tracing/core.py +249 -0
  43. morphsdk/tracing/interaction.py +284 -0
  44. morphsdk/tracing/otel.py +75 -0
  45. morphsdk/tracing/reflex.py +58 -0
  46. morphsdk/tracing/types.py +163 -0
  47. morphsdk/types/__init__.py +140 -0
  48. morphsdk/types/browser.py +118 -0
  49. morphsdk/types/compact.py +41 -0
  50. morphsdk/types/edit.py +31 -0
  51. morphsdk/types/explore.py +42 -0
  52. morphsdk/types/git.py +25 -0
  53. morphsdk/types/github.py +111 -0
  54. morphsdk/types/grep.py +41 -0
  55. morphsdk/types/mobile.py +25 -0
  56. morphsdk/types/reflex.py +137 -0
  57. morphsdk/types/router.py +21 -0
  58. morphsdk/types/search.py +33 -0
  59. morphsdk-0.2.5.dist-info/METADATA +226 -0
  60. morphsdk-0.2.5.dist-info/RECORD +61 -0
  61. morphsdk-0.2.5.dist-info/WHEEL +4 -0
@@ -0,0 +1,171 @@
1
+ """Tool execution against a :class:`WarpGrepProvider`.
2
+
3
+ Faithful port of ``agent/tools/*.ts`` and the ``executeTool`` dispatcher in
4
+ ``agent/runner.ts``. Each function turns a model-issued tool call into a single
5
+ provider call and formats the output string exactly as the TS does (the strings
6
+ are part of the model's training distribution).
7
+
8
+ Note on ``glob``: the TypeScript ``WarpGrepProvider`` interface exposes a
9
+ ``glob`` method, but the **Python** provider base
10
+ (:class:`morphsdk._providers.base.WarpGrepProvider`) intentionally does not. The
11
+ ``glob`` tool is still advertised to the model (it is part of the wire tool
12
+ schema, so we keep it byte-for-byte), but when the active provider lacks a
13
+ ``glob`` implementation we return the standard ``"no matches"`` string rather
14
+ than inventing a second search backend. See the port report.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ from morphsdk._providers.base import WarpGrepProvider
22
+
23
+ from .parser import extract_path_from_command, parse_read_lines
24
+
25
+
26
+ async def _tool_grep(
27
+ provider: WarpGrepProvider,
28
+ *,
29
+ pattern: str,
30
+ path: str,
31
+ glob: str | None = None,
32
+ case_sensitive: bool | None = None,
33
+ ) -> str:
34
+ """Run grep and format like ``toolGrep``: error text, ``"no matches"``, or lines."""
35
+ res = await provider.grep(
36
+ pattern=pattern, path=path, glob=glob, case_sensitive=case_sensitive
37
+ )
38
+ if res.error:
39
+ return res.error
40
+ if not res.lines:
41
+ return "no matches"
42
+ return "\n".join(res.lines)
43
+
44
+
45
+ async def _tool_read(
46
+ provider: WarpGrepProvider,
47
+ *,
48
+ path: str,
49
+ start: int | None = None,
50
+ end: int | None = None,
51
+ lines: list[tuple[int, int]] | None = None,
52
+ ) -> str:
53
+ """Read a file or ranges, formatted like ``toolRead`` (``\\n...\\n`` between blocks)."""
54
+ if lines:
55
+ valid_ranges = [
56
+ (s, e)
57
+ for s, e in lines
58
+ if isinstance(s, int) and isinstance(e, int) and s > 0 and e >= s
59
+ ]
60
+ if not valid_ranges:
61
+ res = await provider.read(path=path)
62
+ if res.error:
63
+ return res.error
64
+ return "\n".join(res.lines) if res.lines else "(empty file)"
65
+ chunks: list[str] = []
66
+ for s, e in valid_ranges:
67
+ res = await provider.read(path=path, start_line=s, end_line=e)
68
+ if res.error:
69
+ return res.error
70
+ chunks.append("\n".join(res.lines))
71
+ if all(c == "" for c in chunks):
72
+ return "(empty file)"
73
+ return "\n...\n".join(chunks)
74
+
75
+ res = await provider.read(path=path, start_line=start, end_line=end)
76
+ if res.error:
77
+ return res.error
78
+ return "\n".join(res.lines) if res.lines else "(empty file)"
79
+
80
+
81
+ async def _tool_list_directory(
82
+ provider: WarpGrepProvider,
83
+ *,
84
+ path: str,
85
+ repo_root: str | None = None,
86
+ ) -> str:
87
+ """List a directory, returning absolute paths (one per line) like ``toolListDirectory``."""
88
+ import os
89
+
90
+ entries = await provider.list_directory(path=path)
91
+ if not entries:
92
+ return "empty"
93
+ if repo_root:
94
+ return "\n".join(os.path.join(repo_root, e.path) for e in entries)
95
+ return "\n".join(e.path for e in entries)
96
+
97
+
98
+ async def _tool_glob(
99
+ provider: WarpGrepProvider,
100
+ *,
101
+ pattern: str,
102
+ path: str | None = None,
103
+ ) -> str:
104
+ """Glob fallback. The Python provider base has no ``glob``; if the concrete
105
+ provider supplies one (duck-typed, TS-shaped), use it; otherwise degrade to
106
+ the standard ``"no matches"`` string."""
107
+ glob_fn = getattr(provider, "glob", None)
108
+ if glob_fn is None:
109
+ return "no matches"
110
+ res = await glob_fn(pattern=pattern, path=path)
111
+ error = getattr(res, "error", None)
112
+ files = getattr(res, "files", [])
113
+ if error:
114
+ return str(error)
115
+ if not files:
116
+ return "no matches"
117
+ total_found = getattr(res, "total_found", len(files))
118
+ search_dir = getattr(res, "search_dir", path or ".")
119
+ header = (
120
+ f'Found {total_found} file(s) matching "{pattern}" within {search_dir}, '
121
+ "sorted by modification time (newest first):"
122
+ )
123
+ body = "\n".join(files)
124
+ truncated = (
125
+ f"\n[{total_found - len(files)} files truncated]" if total_found > len(files) else ""
126
+ )
127
+ return f"{header}\n---\n{body}\n---{truncated}"
128
+
129
+
130
+ async def execute_tool(
131
+ provider: WarpGrepProvider,
132
+ name: str,
133
+ args: dict[str, Any],
134
+ repo_root: str | None = None,
135
+ ) -> str:
136
+ """Dispatch a single tool call to the provider (mirrors ``executeTool``)."""
137
+ if name == "grep_search":
138
+ output = await _tool_grep(
139
+ provider,
140
+ pattern=str(args.get("pattern", "")),
141
+ path=str(args.get("path") or "."),
142
+ glob=args["glob"] if args.get("glob") else None,
143
+ case_sensitive=(
144
+ bool(args["case_sensitive"]) if args.get("case_sensitive") is not None else None
145
+ ),
146
+ )
147
+ limit = args.get("limit")
148
+ if isinstance(limit, int):
149
+ out_lines = output.split("\n")
150
+ if len(out_lines) > limit:
151
+ output = "\n".join(out_lines[:limit]) + f"\n... (truncated at {limit} lines)"
152
+ return output
153
+
154
+ if name == "glob":
155
+ path = args.get("path")
156
+ return await _tool_glob(
157
+ provider, pattern=str(args.get("pattern", "")), path=str(path) if path else None
158
+ )
159
+
160
+ if name == "list_directory":
161
+ dir_path = extract_path_from_command(str(args.get("command") or "."))
162
+ return await _tool_list_directory(provider, path=dir_path, repo_root=repo_root)
163
+
164
+ if name == "read":
165
+ read_kwargs: dict[str, Any] = {"path": str(args.get("path", ""))}
166
+ lines_arg = args.get("lines")
167
+ if isinstance(lines_arg, str):
168
+ read_kwargs.update(parse_read_lines(lines_arg))
169
+ return await _tool_read(provider, **read_kwargs)
170
+
171
+ return f"Unknown tool: {name}"
@@ -0,0 +1,126 @@
1
+ """Core types for the WarpGrep multi-turn agent loop.
2
+
3
+ Faithful port of ``tools/warp_grep/agent/types.ts``. The chat-message and
4
+ tool-call shapes mirror the OpenAI chat-completions wire format exactly, because
5
+ they are serialized straight onto the request body sent to the Morph API.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Literal, Union
12
+
13
+ # A finish file spec: a path plus either "*" (whole file) or explicit line ranges.
14
+ FinishLines = Union[Literal["*"], list[tuple[int, int]]]
15
+
16
+
17
+ @dataclass
18
+ class ToolCallRef:
19
+ """A single tool call emitted by the model (OpenAI function-call shape)."""
20
+
21
+ id: str
22
+ name: str
23
+ arguments: str # raw JSON string, exactly as returned by the model
24
+ type: Literal["function"] = "function"
25
+
26
+
27
+ @dataclass
28
+ class ChatMessage:
29
+ """One message in the conversation, serializable to the OpenAI wire shape.
30
+
31
+ ``role`` is one of ``system`` | ``user`` | ``assistant`` | ``tool``.
32
+ Only ``assistant`` carries ``tool_calls``; only ``tool`` carries
33
+ ``tool_call_id``.
34
+ """
35
+
36
+ role: Literal["system", "user", "assistant", "tool"]
37
+ content: str | None
38
+ tool_calls: list[ToolCallRef] | None = None
39
+ tool_call_id: str | None = None
40
+
41
+ def to_wire(self) -> dict[str, Any]:
42
+ """Serialize to the exact JSON the chat-completions endpoint expects."""
43
+ if self.role == "tool":
44
+ return {
45
+ "role": "tool",
46
+ "tool_call_id": self.tool_call_id,
47
+ "content": self.content,
48
+ }
49
+ if self.role == "assistant":
50
+ msg: dict[str, Any] = {"role": "assistant", "content": self.content}
51
+ if self.tool_calls:
52
+ msg["tool_calls"] = [
53
+ {
54
+ "id": tc.id,
55
+ "type": tc.type,
56
+ "function": {"name": tc.name, "arguments": tc.arguments},
57
+ }
58
+ for tc in self.tool_calls
59
+ ]
60
+ return msg
61
+ return {"role": self.role, "content": self.content}
62
+
63
+
64
+ @dataclass
65
+ class FinishFileSpec:
66
+ """A file the model wants returned, with full-file or ranged selection."""
67
+
68
+ path: str
69
+ lines: FinishLines
70
+
71
+
72
+ @dataclass
73
+ class ResolvedContext:
74
+ """A resolved finish file: ranges plus the actual concatenated content."""
75
+
76
+ path: str
77
+ ranges: FinishLines
78
+ content: str
79
+
80
+
81
+ @dataclass
82
+ class AgentFinish:
83
+ """The model's finish payload metadata (the files it selected)."""
84
+
85
+ files: list[FinishFileSpec]
86
+
87
+
88
+ @dataclass
89
+ class WarpGrepTurnMetrics:
90
+ turn: int
91
+ morph_api_ms: int = 0
92
+ local_tools_ms: int = 0
93
+
94
+
95
+ @dataclass
96
+ class WarpGrepExecutionMetrics:
97
+ timeout_ms: int = 0
98
+ total_ms: int = 0
99
+ initial_state_ms: int = 0
100
+ finish_resolution_ms: int = 0
101
+ turns: list[WarpGrepTurnMetrics] = field(default_factory=list)
102
+
103
+
104
+ @dataclass
105
+ class WarpGrepStep:
106
+ """A single streamed step: the turn number and the tool calls made."""
107
+
108
+ turn: int
109
+ tool_calls: list[dict[str, Any]]
110
+
111
+
112
+ @dataclass
113
+ class AgentRunResult:
114
+ """Final result of a complete agent run.
115
+
116
+ Mirrors the TS ``AgentRunResult``. ``finish`` is populated only when
117
+ ``termination_reason == "completed"``.
118
+ """
119
+
120
+ termination_reason: Literal["completed", "terminated", "error"]
121
+ messages: list[ChatMessage]
122
+ finish_payload: str | None = None
123
+ finish_metadata: AgentFinish | None = None
124
+ resolved: list[ResolvedContext] | None = None
125
+ errors: list[dict[str, str]] = field(default_factory=list)
126
+ timings: WarpGrepExecutionMetrics | None = None
morphsdk/_base.py ADDED
@@ -0,0 +1,309 @@
1
+ """Base HTTP client with retry, timeout, and auth.
2
+
3
+ Provides both sync (BaseClient) and async (AsyncBaseClient) variants.
4
+ All resource classes delegate HTTP calls to these.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import time
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from ._config import ClientConfig, RetryConfig
17
+ from ._constants import DEBUG_ENV_VAR, LOG_FILE_ENV_VAR
18
+ from ._errors import (
19
+ APIConnectionError,
20
+ APITimeoutError,
21
+ RateLimitError,
22
+ raise_for_status,
23
+ )
24
+ from ._version import __version__
25
+
26
+ logger = logging.getLogger("morphsdk")
27
+
28
+ _RETRYABLE_STATUS_CODES = {429, 503}
29
+
30
+
31
+ def _configure_logging() -> None:
32
+ """Configure logging based on MORPH_DEBUG and MORPH_LOG_FILE env vars."""
33
+ if os.environ.get(DEBUG_ENV_VAR) == "1" or os.environ.get(LOG_FILE_ENV_VAR):
34
+ logger.setLevel(logging.DEBUG)
35
+
36
+ # Add stderr handler if not already present
37
+ has_stderr = any(
38
+ isinstance(h, logging.StreamHandler) and not isinstance(h, logging.FileHandler)
39
+ for h in logger.handlers
40
+ )
41
+ if not has_stderr:
42
+ sh = logging.StreamHandler()
43
+ sh.setFormatter(
44
+ logging.Formatter("[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s")
45
+ )
46
+ logger.addHandler(sh)
47
+
48
+ # Add file handler if MORPH_LOG_FILE is set
49
+ log_file = os.environ.get(LOG_FILE_ENV_VAR)
50
+ if log_file and not any(isinstance(h, logging.FileHandler) for h in logger.handlers):
51
+ fh = logging.FileHandler(log_file, mode="a")
52
+ fh.setFormatter(
53
+ logging.Formatter(
54
+ '{"ts":"%(asctime)s","level":"%(levelname)s","component":"%(name)s","msg":"%(message)s"}'
55
+ )
56
+ )
57
+ logger.addHandler(fh)
58
+
59
+
60
+ _configure_logging()
61
+
62
+
63
+ def _default_headers() -> dict[str, str]:
64
+ return {
65
+ "X-Morph-SDK-Version": __version__,
66
+ "X-Morph-SDK-Lang": "python",
67
+ }
68
+
69
+
70
+ class BaseClient:
71
+ """Synchronous HTTP client with retry and auth."""
72
+
73
+ def __init__(self, config: ClientConfig) -> None:
74
+ self._config = config
75
+ self._http = httpx.Client(
76
+ timeout=httpx.Timeout(config.timeout),
77
+ headers=_default_headers(),
78
+ )
79
+
80
+ @property
81
+ def api_key(self) -> str:
82
+ return self._config.api_key
83
+
84
+ @property
85
+ def debug(self) -> bool:
86
+ return self._config.debug
87
+
88
+ def _request(
89
+ self,
90
+ method: str,
91
+ url: str,
92
+ *,
93
+ json: Any | None = None,
94
+ params: dict[str, str] | None = None,
95
+ timeout: float | None = None,
96
+ headers: dict[str, str] | None = None,
97
+ retry_config: RetryConfig | None = None,
98
+ ) -> httpx.Response:
99
+ """Make an authenticated HTTP request with retry."""
100
+ rc = retry_config or self._config.retry_config
101
+ effective_timeout = timeout or self._config.timeout
102
+
103
+ req_headers = {"Authorization": f"Bearer {self._config.api_key}"}
104
+ if json is not None:
105
+ req_headers["Content-Type"] = "application/json"
106
+ if headers:
107
+ req_headers.update(headers)
108
+
109
+ last_exc: Exception | None = None
110
+ delay = rc.initial_delay
111
+
112
+ for attempt in range(rc.max_retries + 1):
113
+ try:
114
+ if self._config.debug:
115
+ logger.debug(f"[morphsdk] {method} {url} (attempt {attempt + 1})")
116
+
117
+ response = self._http.request(
118
+ method,
119
+ url,
120
+ json=json,
121
+ params=params,
122
+ headers=req_headers,
123
+ timeout=effective_timeout,
124
+ )
125
+
126
+ if response.status_code in _RETRYABLE_STATUS_CODES and attempt < rc.max_retries:
127
+ retry_after = response.headers.get("Retry-After")
128
+ wait = float(retry_after) if retry_after else min(delay, rc.max_delay)
129
+ if rc.on_retry:
130
+ rc.on_retry(
131
+ attempt + 1,
132
+ RateLimitError(f"HTTP {response.status_code}"),
133
+ )
134
+ time.sleep(wait)
135
+ delay *= rc.backoff_multiplier
136
+ continue
137
+
138
+ if not response.is_success:
139
+ text = response.text
140
+ try:
141
+ data = response.json()
142
+ text = (
143
+ data.get("error")
144
+ or data.get("detail")
145
+ or data.get("message")
146
+ or text
147
+ )
148
+ except Exception:
149
+ pass
150
+ raise_for_status(response.status_code, str(text))
151
+
152
+ return response
153
+
154
+ except (httpx.ConnectError, httpx.RemoteProtocolError) as e:
155
+ last_exc = e
156
+ if attempt < rc.max_retries:
157
+ if rc.on_retry:
158
+ rc.on_retry(attempt + 1, e)
159
+ time.sleep(min(delay, rc.max_delay))
160
+ delay *= rc.backoff_multiplier
161
+ continue
162
+ raise APIConnectionError(str(e)) from e
163
+
164
+ except httpx.TimeoutException as e:
165
+ last_exc = e
166
+ if attempt < rc.max_retries:
167
+ if rc.on_retry:
168
+ rc.on_retry(attempt + 1, e)
169
+ time.sleep(min(delay, rc.max_delay))
170
+ delay *= rc.backoff_multiplier
171
+ continue
172
+ raise APITimeoutError(str(e)) from e
173
+
174
+ raise (
175
+ APIConnectionError(str(last_exc))
176
+ if last_exc
177
+ else APIConnectionError("Max retries exceeded")
178
+ )
179
+
180
+ def close(self) -> None:
181
+ self._http.close()
182
+
183
+ def __enter__(self) -> BaseClient:
184
+ return self
185
+
186
+ def __exit__(self, *args: Any) -> None:
187
+ self.close()
188
+
189
+
190
+ class AsyncBaseClient:
191
+ """Asynchronous HTTP client with retry and auth."""
192
+
193
+ def __init__(self, config: ClientConfig) -> None:
194
+ self._config = config
195
+ self._http = httpx.AsyncClient(
196
+ timeout=httpx.Timeout(config.timeout),
197
+ headers=_default_headers(),
198
+ )
199
+
200
+ @property
201
+ def api_key(self) -> str:
202
+ return self._config.api_key
203
+
204
+ @property
205
+ def debug(self) -> bool:
206
+ return self._config.debug
207
+
208
+ async def _request(
209
+ self,
210
+ method: str,
211
+ url: str,
212
+ *,
213
+ json: Any | None = None,
214
+ params: dict[str, str] | None = None,
215
+ timeout: float | None = None,
216
+ headers: dict[str, str] | None = None,
217
+ retry_config: RetryConfig | None = None,
218
+ ) -> httpx.Response:
219
+ """Make an authenticated async HTTP request with retry."""
220
+ import asyncio
221
+
222
+ rc = retry_config or self._config.retry_config
223
+ effective_timeout = timeout or self._config.timeout
224
+
225
+ req_headers = {"Authorization": f"Bearer {self._config.api_key}"}
226
+ if json is not None:
227
+ req_headers["Content-Type"] = "application/json"
228
+ if headers:
229
+ req_headers.update(headers)
230
+
231
+ last_exc: Exception | None = None
232
+ delay = rc.initial_delay
233
+
234
+ for attempt in range(rc.max_retries + 1):
235
+ try:
236
+ if self._config.debug:
237
+ logger.debug(f"[morphsdk] {method} {url} (attempt {attempt + 1})")
238
+
239
+ response = await self._http.request(
240
+ method,
241
+ url,
242
+ json=json,
243
+ params=params,
244
+ headers=req_headers,
245
+ timeout=effective_timeout,
246
+ )
247
+
248
+ if response.status_code in _RETRYABLE_STATUS_CODES and attempt < rc.max_retries:
249
+ retry_after = response.headers.get("Retry-After")
250
+ wait = float(retry_after) if retry_after else min(delay, rc.max_delay)
251
+ if rc.on_retry:
252
+ rc.on_retry(
253
+ attempt + 1,
254
+ RateLimitError(f"HTTP {response.status_code}"),
255
+ )
256
+ await asyncio.sleep(wait)
257
+ delay *= rc.backoff_multiplier
258
+ continue
259
+
260
+ if not response.is_success:
261
+ text = response.text
262
+ try:
263
+ data = response.json()
264
+ text = (
265
+ data.get("error")
266
+ or data.get("detail")
267
+ or data.get("message")
268
+ or text
269
+ )
270
+ except Exception:
271
+ pass
272
+ raise_for_status(response.status_code, str(text))
273
+
274
+ return response
275
+
276
+ except (httpx.ConnectError, httpx.RemoteProtocolError) as e:
277
+ last_exc = e
278
+ if attempt < rc.max_retries:
279
+ if rc.on_retry:
280
+ rc.on_retry(attempt + 1, e)
281
+ await asyncio.sleep(min(delay, rc.max_delay))
282
+ delay *= rc.backoff_multiplier
283
+ continue
284
+ raise APIConnectionError(str(e)) from e
285
+
286
+ except httpx.TimeoutException as e:
287
+ last_exc = e
288
+ if attempt < rc.max_retries:
289
+ if rc.on_retry:
290
+ rc.on_retry(attempt + 1, e)
291
+ await asyncio.sleep(min(delay, rc.max_delay))
292
+ delay *= rc.backoff_multiplier
293
+ continue
294
+ raise APITimeoutError(str(e)) from e
295
+
296
+ raise (
297
+ APIConnectionError(str(last_exc))
298
+ if last_exc
299
+ else APIConnectionError("Max retries exceeded")
300
+ )
301
+
302
+ async def close(self) -> None:
303
+ await self._http.aclose()
304
+
305
+ async def __aenter__(self) -> AsyncBaseClient:
306
+ return self
307
+
308
+ async def __aexit__(self, *args: Any) -> None:
309
+ await self.close()