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.
- morphsdk/__init__.py +54 -0
- morphsdk/_agent/__init__.py +64 -0
- morphsdk/_agent/config.py +52 -0
- morphsdk/_agent/explore.py +276 -0
- morphsdk/_agent/github.py +57 -0
- morphsdk/_agent/helpers.py +133 -0
- morphsdk/_agent/parser.py +163 -0
- morphsdk/_agent/runner.py +524 -0
- morphsdk/_agent/tools.py +171 -0
- morphsdk/_agent/types.py +126 -0
- morphsdk/_base.py +309 -0
- morphsdk/_client.py +245 -0
- morphsdk/_config.py +37 -0
- morphsdk/_constants.py +53 -0
- morphsdk/_errors.py +111 -0
- morphsdk/_providers/__init__.py +36 -0
- morphsdk/_providers/_filter.py +92 -0
- morphsdk/_providers/base.py +94 -0
- morphsdk/_providers/code_storage_http.py +104 -0
- morphsdk/_providers/local.py +270 -0
- morphsdk/_providers/remote.py +161 -0
- morphsdk/_version.py +1 -0
- morphsdk/adapters/__init__.py +1 -0
- morphsdk/adapters/anthropic.py +360 -0
- morphsdk/adapters/langchain.py +120 -0
- morphsdk/adapters/openai.py +500 -0
- morphsdk/py.typed +0 -0
- morphsdk/resources/__init__.py +0 -0
- morphsdk/resources/browser.py +919 -0
- morphsdk/resources/compact.py +133 -0
- morphsdk/resources/edit.py +506 -0
- morphsdk/resources/explore.py +333 -0
- morphsdk/resources/git.py +861 -0
- morphsdk/resources/github.py +1214 -0
- morphsdk/resources/grep.py +583 -0
- morphsdk/resources/mobile.py +134 -0
- morphsdk/resources/reflex.py +414 -0
- morphsdk/resources/router.py +124 -0
- morphsdk/resources/search.py +110 -0
- morphsdk/tracing/__init__.py +70 -0
- morphsdk/tracing/_otel.py +101 -0
- morphsdk/tracing/core.py +249 -0
- morphsdk/tracing/interaction.py +284 -0
- morphsdk/tracing/otel.py +75 -0
- morphsdk/tracing/reflex.py +58 -0
- morphsdk/tracing/types.py +163 -0
- morphsdk/types/__init__.py +140 -0
- morphsdk/types/browser.py +118 -0
- morphsdk/types/compact.py +41 -0
- morphsdk/types/edit.py +31 -0
- morphsdk/types/explore.py +42 -0
- morphsdk/types/git.py +25 -0
- morphsdk/types/github.py +111 -0
- morphsdk/types/grep.py +41 -0
- morphsdk/types/mobile.py +25 -0
- morphsdk/types/reflex.py +137 -0
- morphsdk/types/router.py +21 -0
- morphsdk/types/search.py +33 -0
- morphsdk-0.2.5.dist-info/METADATA +226 -0
- morphsdk-0.2.5.dist-info/RECORD +61 -0
- morphsdk-0.2.5.dist-info/WHEEL +4 -0
morphsdk/_agent/tools.py
ADDED
|
@@ -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}"
|
morphsdk/_agent/types.py
ADDED
|
@@ -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()
|