python-corekit 0.4.1__py3-none-any.whl → 0.5.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.
- corekit/connections/sql/connection.py +6 -2
- corekit/html/__init__.py +22 -0
- corekit/html/client.py +22 -0
- corekit/html/models.py +20 -0
- corekit/html/parser.py +272 -0
- corekit/html/scraper.py +127 -0
- corekit/html/settings.py +197 -0
- corekit/html/state.py +56 -0
- corekit/http/__init__.py +2 -1
- corekit/http/api.py +71 -3
- corekit/http/client.py +24 -54
- corekit/http/response.py +38 -5
- corekit/llm/client.py +3 -2
- corekit/llm/tools/loop.py +5 -2
- corekit/llm/wire.py +11 -5
- corekit/schemas/__init__.py +8 -1
- corekit/schemas/models/arbitrary.py +49 -5
- corekit/utils/__init__.py +48 -7
- corekit/utils/coercion.py +45 -59
- corekit/utils/collections.py +136 -3
- corekit/utils/parsers.py +205 -0
- {python_corekit-0.4.1.dist-info → python_corekit-0.5.0.dist-info}/METADATA +8 -7
- {python_corekit-0.4.1.dist-info → python_corekit-0.5.0.dist-info}/RECORD +26 -18
- {python_corekit-0.4.1.dist-info → python_corekit-0.5.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.4.1.dist-info → python_corekit-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.4.1.dist-info → python_corekit-0.5.0.dist-info}/top_level.txt +0 -0
corekit/html/state.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from corekit.utils import safe_list
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ScraperState:
|
|
8
|
+
# Sentinel value to signal the end of the stream
|
|
9
|
+
FINISHED_TOKEN = object()
|
|
10
|
+
|
|
11
|
+
_seen: set[str]
|
|
12
|
+
_queue: asyncio.Queue[str]
|
|
13
|
+
|
|
14
|
+
def __init__(self, start_urls: list[str] | None = None) -> None:
|
|
15
|
+
self._seen = set()
|
|
16
|
+
self._queue = asyncio.Queue()
|
|
17
|
+
self._output_queue: asyncio.Queue[Any] = asyncio.Queue()
|
|
18
|
+
self._lock: asyncio.Lock = asyncio.Lock()
|
|
19
|
+
|
|
20
|
+
for url in safe_list(start_urls):
|
|
21
|
+
self.enqueue_no_wait(url)
|
|
22
|
+
|
|
23
|
+
def enqueue_no_wait(self, url: str) -> None:
|
|
24
|
+
if url in self._seen:
|
|
25
|
+
return
|
|
26
|
+
self._seen.add(url)
|
|
27
|
+
self._queue.put_nowait(url)
|
|
28
|
+
|
|
29
|
+
async def enqueue(self, url: str) -> None:
|
|
30
|
+
async with self._lock:
|
|
31
|
+
if url in self._seen:
|
|
32
|
+
return
|
|
33
|
+
self._seen.add(url)
|
|
34
|
+
await self._queue.put(url)
|
|
35
|
+
|
|
36
|
+
async def get_next_url(self) -> str:
|
|
37
|
+
return await self._queue.get()
|
|
38
|
+
|
|
39
|
+
def task_done(self) -> None:
|
|
40
|
+
self._queue.task_done()
|
|
41
|
+
|
|
42
|
+
async def record_output(self, data: Any) -> None:
|
|
43
|
+
await self._output_queue.put(data)
|
|
44
|
+
|
|
45
|
+
async def get_output(self) -> Any:
|
|
46
|
+
return await self._output_queue.get()
|
|
47
|
+
|
|
48
|
+
def output_task_done(self) -> None:
|
|
49
|
+
self._output_queue.task_done()
|
|
50
|
+
|
|
51
|
+
def is_empty(self) -> bool:
|
|
52
|
+
return self._queue.empty()
|
|
53
|
+
|
|
54
|
+
async def join(self) -> None:
|
|
55
|
+
await self._queue.join()
|
|
56
|
+
await self.record_output(self.FINISHED_TOKEN)
|
corekit/http/__init__.py
CHANGED
|
@@ -25,7 +25,7 @@ from corekit.http.exceptions import (
|
|
|
25
25
|
URLMismatchError,
|
|
26
26
|
)
|
|
27
27
|
from corekit.http.exponential_backoff import ExponentialBackoff
|
|
28
|
-
from corekit.http.response import BaseApiResponse
|
|
28
|
+
from corekit.http.response import BaseApiResponse, BaseHttpResponse
|
|
29
29
|
from corekit.http.status import HTTPStatusCode
|
|
30
30
|
from corekit.http.stream import SseDecoder, SseJsonDecoder, SseTextDecoder, StreamDecoder
|
|
31
31
|
|
|
@@ -35,6 +35,7 @@ __all__ = [
|
|
|
35
35
|
"BaseApiClient",
|
|
36
36
|
"BaseApiResponse",
|
|
37
37
|
"BaseHttpClient",
|
|
38
|
+
"BaseHttpResponse",
|
|
38
39
|
"ConflictErrorException",
|
|
39
40
|
"ExponentialBackoff",
|
|
40
41
|
"ForbiddenException",
|
corekit/http/api.py
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
"""
|
|
2
|
-
API clients: a ``BaseHttpClient`` that always talks to one origin.
|
|
2
|
+
API clients: a ``BaseHttpClient`` that always talks to one origin and parses JSON.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from http import HTTPMethod
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from corekit.concurrency.decorators import allow_sync
|
|
7
11
|
from corekit.http.client import BaseHttpClient
|
|
12
|
+
from corekit.http.response import BaseApiResponse
|
|
13
|
+
from corekit.http.status import HTTPStatusCode
|
|
8
14
|
|
|
9
15
|
__all__ = ["BaseApiClient"]
|
|
10
16
|
|
|
17
|
+
_API_METHODS = frozenset(
|
|
18
|
+
{
|
|
19
|
+
HTTPMethod.GET,
|
|
20
|
+
HTTPMethod.POST,
|
|
21
|
+
HTTPMethod.PUT,
|
|
22
|
+
HTTPMethod.PATCH,
|
|
23
|
+
HTTPMethod.DELETE,
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
11
27
|
|
|
12
28
|
class BaseApiClient(BaseHttpClient):
|
|
13
29
|
"""
|
|
@@ -16,9 +32,61 @@ class BaseApiClient(BaseHttpClient):
|
|
|
16
32
|
Strict by default, so an absolute URL that does not share ``base_url`` is
|
|
17
33
|
a bug rather than a request to somewhere else. ``base_url`` must be
|
|
18
34
|
available during ``__init__`` — a property on the subclass is the usual way.
|
|
35
|
+
|
|
36
|
+
Supports the usual mutating verbs. Responses are ``BaseApiResponse`` with
|
|
37
|
+
structured ``data`` when the body is JSON.
|
|
19
38
|
"""
|
|
20
39
|
|
|
21
|
-
def __init__(self, *args: Any,
|
|
22
|
-
|
|
40
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
41
|
+
kwargs.setdefault("strict", True)
|
|
42
|
+
super().__init__(*args, **kwargs)
|
|
23
43
|
if not self.base_url:
|
|
24
44
|
raise ValueError("BaseApiClient requires a non-empty base_url")
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def supported_methods(self) -> frozenset[HTTPMethod]:
|
|
48
|
+
"""
|
|
49
|
+
API clients may send the common REST verbs.
|
|
50
|
+
"""
|
|
51
|
+
return _API_METHODS
|
|
52
|
+
|
|
53
|
+
def _to_response(self, raw: httpx.Response) -> BaseApiResponse:
|
|
54
|
+
"""
|
|
55
|
+
Normalize a httpx response into a ``BaseApiResponse`` with parsed ``data``.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
loaded: Any = raw.json()
|
|
59
|
+
except ValueError:
|
|
60
|
+
loaded = None
|
|
61
|
+
|
|
62
|
+
return BaseApiResponse(
|
|
63
|
+
status_code=HTTPStatusCode.coerce(raw.status_code),
|
|
64
|
+
text=raw.text,
|
|
65
|
+
data=BaseApiResponse.parse_data(loaded) if loaded is not None else None,
|
|
66
|
+
headers=dict(raw.headers),
|
|
67
|
+
cookies=dict(raw.cookies),
|
|
68
|
+
url=str(raw.url),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
async def request(self, method: HTTPMethod, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
72
|
+
return await super().request(method, url, **kwargs) # type: ignore[return-value]
|
|
73
|
+
|
|
74
|
+
@allow_sync
|
|
75
|
+
async def get(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
76
|
+
return await self.request(HTTPMethod.GET, url, **kwargs)
|
|
77
|
+
|
|
78
|
+
@allow_sync
|
|
79
|
+
async def post(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
80
|
+
return await self.request(HTTPMethod.POST, url, **kwargs)
|
|
81
|
+
|
|
82
|
+
@allow_sync
|
|
83
|
+
async def put(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
84
|
+
return await self.request(HTTPMethod.PUT, url, **kwargs)
|
|
85
|
+
|
|
86
|
+
@allow_sync
|
|
87
|
+
async def patch(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
88
|
+
return await self.request(HTTPMethod.PATCH, url, **kwargs)
|
|
89
|
+
|
|
90
|
+
@allow_sync
|
|
91
|
+
async def delete(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
92
|
+
return await self.request(HTTPMethod.DELETE, url, **kwargs)
|
corekit/http/client.py
CHANGED
|
@@ -22,10 +22,10 @@ absolute URL should be a bug.
|
|
|
22
22
|
|
|
23
23
|
client = GithubClient()
|
|
24
24
|
response = client.get("/user") # or await client.get("/user")
|
|
25
|
-
response.data
|
|
25
|
+
response.data.login
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
``BaseHttpClient`` returns a ``BaseHttpResponse`` (text + metadata).
|
|
28
|
+
``BaseApiClient`` adds structured JSON on ``data``.
|
|
29
29
|
"""
|
|
30
30
|
|
|
31
31
|
from collections.abc import AsyncIterator
|
|
@@ -39,9 +39,10 @@ import httpx
|
|
|
39
39
|
from corekit.concurrency.decorators import allow_sync
|
|
40
40
|
from corekit.http.exceptions import RETRYABLE_STATUS_CODES, UnsupportedMethodError, URLMismatchError
|
|
41
41
|
from corekit.http.exponential_backoff import ExponentialBackoff
|
|
42
|
-
from corekit.http.response import
|
|
42
|
+
from corekit.http.response import BaseHttpResponse
|
|
43
43
|
from corekit.http.status import HTTPStatusCode
|
|
44
44
|
from corekit.observability.benchmarkable import Benchmarkable
|
|
45
|
+
from corekit.utils import safe_dict
|
|
45
46
|
|
|
46
47
|
__all__ = ["BaseHttpClient"]
|
|
47
48
|
|
|
@@ -54,27 +55,22 @@ def _without_query(url: str) -> str:
|
|
|
54
55
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
|
55
56
|
|
|
56
57
|
|
|
57
|
-
_DEFAULT_METHODS = frozenset(
|
|
58
|
-
{
|
|
59
|
-
HTTPMethod.GET,
|
|
60
|
-
HTTPMethod.POST,
|
|
61
|
-
HTTPMethod.PUT,
|
|
62
|
-
HTTPMethod.PATCH,
|
|
63
|
-
HTTPMethod.DELETE,
|
|
64
|
-
}
|
|
65
|
-
)
|
|
58
|
+
_DEFAULT_METHODS = frozenset({HTTPMethod.GET})
|
|
66
59
|
|
|
67
60
|
|
|
68
61
|
class BaseHttpClient(Benchmarkable):
|
|
69
62
|
"""
|
|
70
63
|
Base for HTTP clients. Override ``base_url`` and, usually, ``headers``.
|
|
71
64
|
|
|
72
|
-
|
|
73
|
-
|
|
65
|
+
GET-only by default — fetching is the transport's job. Subclass
|
|
66
|
+
``BaseApiClient`` (or override ``supported_methods``) when mutating verbs
|
|
67
|
+
are required. ``strict=False`` accepts a foreign absolute URL as-is;
|
|
68
|
+
``BaseApiClient`` turns that into an error.
|
|
74
69
|
"""
|
|
75
70
|
|
|
76
71
|
def __init__(
|
|
77
72
|
self,
|
|
73
|
+
base_url: str = "",
|
|
78
74
|
retries: int = 3,
|
|
79
75
|
timeout: float = 30.0,
|
|
80
76
|
strict: bool = False,
|
|
@@ -82,6 +78,7 @@ class BaseHttpClient(Benchmarkable):
|
|
|
82
78
|
http_client: httpx.AsyncClient | None = None,
|
|
83
79
|
) -> None:
|
|
84
80
|
super().__init__()
|
|
81
|
+
self._base_url = base_url
|
|
85
82
|
self.retries = retries
|
|
86
83
|
self.timeout = timeout
|
|
87
84
|
self.strict = strict
|
|
@@ -93,7 +90,7 @@ class BaseHttpClient(Benchmarkable):
|
|
|
93
90
|
@property
|
|
94
91
|
def supported_methods(self) -> frozenset[HTTPMethod]:
|
|
95
92
|
"""
|
|
96
|
-
HTTP methods this client will send.
|
|
93
|
+
HTTP methods this client will send. Defaults to GET only.
|
|
97
94
|
"""
|
|
98
95
|
return _DEFAULT_METHODS
|
|
99
96
|
|
|
@@ -109,7 +106,7 @@ class BaseHttpClient(Benchmarkable):
|
|
|
109
106
|
"""
|
|
110
107
|
The root every relative path is resolved against.
|
|
111
108
|
"""
|
|
112
|
-
return
|
|
109
|
+
return self._base_url
|
|
113
110
|
|
|
114
111
|
def _build_url(self, url: str) -> str:
|
|
115
112
|
"""
|
|
@@ -124,30 +121,21 @@ class BaseHttpClient(Benchmarkable):
|
|
|
124
121
|
return url
|
|
125
122
|
return urljoin(self.base_url, url)
|
|
126
123
|
|
|
127
|
-
|
|
128
|
-
def _to_response(raw: httpx.Response) -> BaseApiResponse:
|
|
124
|
+
def _to_response(self, raw: httpx.Response) -> BaseHttpResponse:
|
|
129
125
|
"""
|
|
130
|
-
Normalize a httpx response into a
|
|
126
|
+
Normalize a httpx response into a ``BaseHttpResponse``.
|
|
131
127
|
|
|
132
|
-
|
|
133
|
-
so a caller never has to guard against a non-JSON error page.
|
|
128
|
+
Text and metadata only — JSON parsing belongs on ``BaseApiClient``.
|
|
134
129
|
"""
|
|
135
|
-
|
|
136
|
-
data = raw.json()
|
|
137
|
-
except ValueError:
|
|
138
|
-
data = {}
|
|
139
|
-
if not isinstance(data, dict):
|
|
140
|
-
data = {"data": data}
|
|
141
|
-
|
|
142
|
-
return BaseApiResponse(
|
|
130
|
+
return BaseHttpResponse(
|
|
143
131
|
status_code=HTTPStatusCode.coerce(raw.status_code),
|
|
144
132
|
text=raw.text,
|
|
145
|
-
data=data,
|
|
146
133
|
headers=dict(raw.headers),
|
|
147
134
|
cookies=dict(raw.cookies),
|
|
135
|
+
url=str(raw.url),
|
|
148
136
|
)
|
|
149
137
|
|
|
150
|
-
async def request(self, method: HTTPMethod, url: str, **kwargs: Any) ->
|
|
138
|
+
async def request(self, method: HTTPMethod, url: str, **kwargs: Any) -> BaseHttpResponse:
|
|
151
139
|
"""
|
|
152
140
|
Send a request asynchronously, retrying retryable statuses.
|
|
153
141
|
"""
|
|
@@ -156,9 +144,7 @@ class BaseHttpClient(Benchmarkable):
|
|
|
156
144
|
|
|
157
145
|
target = self._build_url(url)
|
|
158
146
|
headers = dict(self.headers)
|
|
159
|
-
|
|
160
|
-
if extra_headers:
|
|
161
|
-
headers.update(extra_headers)
|
|
147
|
+
headers.update(safe_dict(kwargs.pop("headers", {})))
|
|
162
148
|
# Fresh each request: ExponentialBackoff keeps its own attempt count.
|
|
163
149
|
backoff = ExponentialBackoff(retries=self.retries, base_delay=self.base_delay)
|
|
164
150
|
|
|
@@ -176,7 +162,7 @@ class BaseHttpClient(Benchmarkable):
|
|
|
176
162
|
headers: dict[str, str],
|
|
177
163
|
backoff: ExponentialBackoff,
|
|
178
164
|
kwargs: dict[str, Any],
|
|
179
|
-
) ->
|
|
165
|
+
) -> BaseHttpResponse:
|
|
180
166
|
while True:
|
|
181
167
|
raw = await client.request(str(method), target, headers=headers, **kwargs)
|
|
182
168
|
if raw.status_code not in RETRYABLE_STATUS_CODES or backoff.did_timeout():
|
|
@@ -191,7 +177,7 @@ class BaseHttpClient(Benchmarkable):
|
|
|
191
177
|
Open a streaming response and yield the raw ``httpx.Response``.
|
|
192
178
|
|
|
193
179
|
Holds the connection for the life of the ``async with`` block. Unlike
|
|
194
|
-
``request``, the body is not buffered into a
|
|
180
|
+
``request``, the body is not buffered into a response model — the
|
|
195
181
|
caller reads it (for example with ``aiter_lines`` for SSE). Retries are
|
|
196
182
|
not applied: a mid-stream failure cannot be replayed safely.
|
|
197
183
|
"""
|
|
@@ -214,21 +200,5 @@ class BaseHttpClient(Benchmarkable):
|
|
|
214
200
|
yield response
|
|
215
201
|
|
|
216
202
|
@allow_sync
|
|
217
|
-
async def get(self, url: str, **kwargs: Any) ->
|
|
203
|
+
async def get(self, url: str, **kwargs: Any) -> BaseHttpResponse:
|
|
218
204
|
return await self.request(HTTPMethod.GET, url, **kwargs)
|
|
219
|
-
|
|
220
|
-
@allow_sync
|
|
221
|
-
async def post(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
222
|
-
return await self.request(HTTPMethod.POST, url, **kwargs)
|
|
223
|
-
|
|
224
|
-
@allow_sync
|
|
225
|
-
async def put(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
226
|
-
return await self.request(HTTPMethod.PUT, url, **kwargs)
|
|
227
|
-
|
|
228
|
-
@allow_sync
|
|
229
|
-
async def patch(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
230
|
-
return await self.request(HTTPMethod.PATCH, url, **kwargs)
|
|
231
|
-
|
|
232
|
-
@allow_sync
|
|
233
|
-
async def delete(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
234
|
-
return await self.request(HTTPMethod.DELETE, url, **kwargs)
|
corekit/http/response.py
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
1
5
|
from pydantic import BaseModel, Field
|
|
2
6
|
|
|
3
7
|
from corekit.exceptions import CoreHTTPException
|
|
@@ -14,16 +18,23 @@ from corekit.http.exceptions import (
|
|
|
14
18
|
UnprocessableEntityException,
|
|
15
19
|
)
|
|
16
20
|
from corekit.http.status import HTTPStatusCode
|
|
17
|
-
from corekit.schemas.
|
|
21
|
+
from corekit.schemas.models.arbitrary import ArbitraryModel
|
|
22
|
+
from corekit.schemas.pydantic.fields import DefaultDictField, DefaultEmptyField
|
|
18
23
|
from corekit.schemas.types import AnyDict
|
|
19
24
|
|
|
20
25
|
|
|
21
|
-
class
|
|
26
|
+
class BaseHttpResponse(BaseModel):
|
|
27
|
+
"""
|
|
28
|
+
Transport-level HTTP response: status, decoded body text, and metadata.
|
|
29
|
+
|
|
30
|
+
No JSON parsing happens here — subclasses add structured ``data`` when needed.
|
|
31
|
+
"""
|
|
32
|
+
|
|
22
33
|
status_code: HTTPStatusCode | int = Field(..., description="HTTP status code")
|
|
23
|
-
text: str = Field(..., description="
|
|
24
|
-
data: AnyDict = DefaultDictField()
|
|
34
|
+
text: str = Field(..., description="Decoded response body")
|
|
25
35
|
headers: AnyDict = DefaultDictField()
|
|
26
36
|
cookies: AnyDict = DefaultDictField()
|
|
37
|
+
url: str = Field(default="", description="Final response URL after redirects")
|
|
27
38
|
|
|
28
39
|
@property
|
|
29
40
|
def ok(self) -> bool:
|
|
@@ -32,7 +43,7 @@ class BaseApiResponse(BaseModel):
|
|
|
32
43
|
"""
|
|
33
44
|
return int(self.status_code) < 400
|
|
34
45
|
|
|
35
|
-
def raise_for_status(self) ->
|
|
46
|
+
def raise_for_status(self) -> BaseHttpResponse:
|
|
36
47
|
"""
|
|
37
48
|
Raise a typed HTTP exception for a failure status.
|
|
38
49
|
|
|
@@ -59,3 +70,25 @@ class BaseApiResponse(BaseModel):
|
|
|
59
70
|
if exception_type is None:
|
|
60
71
|
raise CoreHTTPException(status_code=int(self.status_code), detail=detail)
|
|
61
72
|
raise exception_type(detail=detail)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class BaseApiResponse(BaseHttpResponse):
|
|
76
|
+
"""
|
|
77
|
+
API response with structured ``data`` parsed from JSON when present.
|
|
78
|
+
|
|
79
|
+
``data`` keeps the wire shape: objects become ``ArbitraryModel``, arrays stay
|
|
80
|
+
lists, primitives stay primitives, and non-JSON bodies yield ``None``.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
data: Any = DefaultEmptyField(description="Structured JSON body, if any")
|
|
84
|
+
|
|
85
|
+
def raise_for_status(self) -> BaseApiResponse:
|
|
86
|
+
super().raise_for_status()
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def parse_data(raw_json: Any) -> Any:
|
|
91
|
+
"""
|
|
92
|
+
Coerce a decoded JSON value into structured ``data``.
|
|
93
|
+
"""
|
|
94
|
+
return ArbitraryModel.coerce(raw_json)
|
corekit/llm/client.py
CHANGED
|
@@ -15,6 +15,7 @@ from typing import Any, Literal, overload
|
|
|
15
15
|
from corekit.concurrency.decorators import allow_sync
|
|
16
16
|
from corekit.http import BaseApiClient, BaseApiResponse, SseJsonDecoder
|
|
17
17
|
from corekit.llm.messages import ChatTurn, Message
|
|
18
|
+
from corekit.schemas import ArbitraryModel
|
|
18
19
|
|
|
19
20
|
__all__ = ["ChatCompletionsClient", "DEFAULT_LLM_TIMEOUT"]
|
|
20
21
|
|
|
@@ -162,9 +163,9 @@ class ChatCompletionsClient(BaseApiClient):
|
|
|
162
163
|
)
|
|
163
164
|
response = await self.post("chat/completions", json=body)
|
|
164
165
|
response.raise_for_status()
|
|
165
|
-
if not response.data:
|
|
166
|
+
if not isinstance(response.data, ArbitraryModel):
|
|
166
167
|
raise ValueError(f"chat/completions returned non-JSON body: {response.text!r}")
|
|
167
|
-
return
|
|
168
|
+
return response.data.model_dump()
|
|
168
169
|
|
|
169
170
|
async def _complete_stream(self, body: dict[str, Any]) -> AsyncIterator[dict[str, Any]]:
|
|
170
171
|
self.info(
|
corekit/llm/tools/loop.py
CHANGED
|
@@ -24,7 +24,7 @@ from corekit.llm.tools.detection import MAX_TOOL_ITERATIONS, ToolCallLoopDetecto
|
|
|
24
24
|
from corekit.llm.tools.registry import ToolRegistry
|
|
25
25
|
from corekit.llm.wire import ChoiceDelta, CompletionTurn, UsageInfo
|
|
26
26
|
from corekit.observability import Benchmarkable
|
|
27
|
-
from corekit.utils
|
|
27
|
+
from corekit.utils import parse_dict
|
|
28
28
|
|
|
29
29
|
__all__ = ["ToolLoop"]
|
|
30
30
|
|
|
@@ -224,12 +224,15 @@ class ToolLoop(Benchmarkable):
|
|
|
224
224
|
async def _execute_ui_tool(self, call: ToolCall) -> UIComponentEvent:
|
|
225
225
|
result = await self._registry.aexecute(call.name, call.id, call.arguments)
|
|
226
226
|
self.info(f"[ToolLoop] UI result — is_error: {result.is_error}")
|
|
227
|
+
parsed = parse_dict(result.content, default={})
|
|
227
228
|
if result.is_error:
|
|
228
229
|
self.warning(f"[ToolLoop] UI tool failed: {result.content}")
|
|
230
|
+
elif not parsed.success:
|
|
231
|
+
self.warning(f"[ToolLoop] UI tool {call.name!r} returned non-mapping component: {result.content!r}")
|
|
229
232
|
return UIComponentEvent(
|
|
230
233
|
tool_call_id=call.id,
|
|
231
234
|
name=call.name,
|
|
232
|
-
component=
|
|
235
|
+
component=parsed.value,
|
|
233
236
|
)
|
|
234
237
|
|
|
235
238
|
def _record_metrics(
|
corekit/llm/wire.py
CHANGED
|
@@ -13,7 +13,7 @@ from pydantic import BaseModel, Field
|
|
|
13
13
|
from corekit.llm.enum import WireField
|
|
14
14
|
from corekit.llm.events import UsageEvent
|
|
15
15
|
from corekit.llm.tools.base import ToolCall
|
|
16
|
-
from corekit.utils import safe_dict, safe_string
|
|
16
|
+
from corekit.utils import parse_dict, safe_dict, safe_string
|
|
17
17
|
from corekit.utils.collections import attr_or_key
|
|
18
18
|
|
|
19
19
|
__all__ = [
|
|
@@ -89,12 +89,18 @@ class ToolCallAccumulator(BaseModel):
|
|
|
89
89
|
"""
|
|
90
90
|
Parse accumulated argument JSON into a ``ToolCall``.
|
|
91
91
|
"""
|
|
92
|
-
parsed: dict[str, Any] = {}
|
|
93
92
|
try:
|
|
94
|
-
|
|
93
|
+
loaded: Any = json.loads(self.arguments)
|
|
95
94
|
except json.JSONDecodeError:
|
|
96
|
-
logger.warning("Bad tool args for
|
|
97
|
-
|
|
95
|
+
logger.warning(f"Bad tool args for {self.name!r}: {self.arguments!r}")
|
|
96
|
+
return ToolCall(id=self.id, name=self.name, arguments={})
|
|
97
|
+
|
|
98
|
+
parsed = parse_dict(loaded, default={})
|
|
99
|
+
if not parsed.success:
|
|
100
|
+
logger.warning(
|
|
101
|
+
f"Tool args for {self.name!r} decoded to non-mapping {type(loaded).__name__}: {self.arguments!r}",
|
|
102
|
+
)
|
|
103
|
+
return ToolCall(id=self.id, name=self.name, arguments=parsed.value)
|
|
98
104
|
|
|
99
105
|
|
|
100
106
|
class ChoiceDelta(BaseModel):
|
corekit/schemas/__init__.py
CHANGED
|
@@ -6,6 +6,13 @@ raises where it is introduced rather than somewhere further along.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from corekit.schemas.enum import IntegerEnum, StringEnum, ValidatingEnum
|
|
9
|
+
from corekit.schemas.models.arbitrary import ArbitraryModel
|
|
9
10
|
from corekit.schemas.version import SemanticVersion
|
|
10
11
|
|
|
11
|
-
__all__ = [
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ArbitraryModel",
|
|
14
|
+
"IntegerEnum",
|
|
15
|
+
"SemanticVersion",
|
|
16
|
+
"StringEnum",
|
|
17
|
+
"ValidatingEnum",
|
|
18
|
+
]
|
|
@@ -1,11 +1,55 @@
|
|
|
1
|
-
|
|
1
|
+
"""
|
|
2
|
+
Recursive attribute access over unstructured JSON-shaped data.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, model_validator
|
|
2
10
|
|
|
3
11
|
|
|
4
12
|
class ArbitraryModel(BaseModel):
|
|
5
13
|
"""
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
14
|
+
Accept any object payload and expose nested dicts as models.
|
|
15
|
+
|
|
16
|
+
Lists keep their shape; dict elements inside them are coerced the same way.
|
|
17
|
+
``model_dump()`` returns plain nested dicts; ``model["key"]`` works for
|
|
18
|
+
callers that still treat the payload like a mapping.
|
|
9
19
|
"""
|
|
10
20
|
|
|
11
|
-
|
|
21
|
+
model_config = ConfigDict(extra="allow")
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def coerce(cls, value: Any) -> Any:
|
|
25
|
+
"""
|
|
26
|
+
Recursively turn dicts into ``ArbitraryModel`` instances.
|
|
27
|
+
"""
|
|
28
|
+
if isinstance(value, cls):
|
|
29
|
+
return value
|
|
30
|
+
if isinstance(value, Mapping) and not isinstance(value, (str, bytes)):
|
|
31
|
+
return cls.model_validate(dict(value))
|
|
32
|
+
if isinstance(value, list):
|
|
33
|
+
return [cls.coerce(item) for item in value]
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
@model_validator(mode="after")
|
|
37
|
+
def _coerce_extras(self) -> ArbitraryModel:
|
|
38
|
+
extras = self.__pydantic_extra__
|
|
39
|
+
if not extras:
|
|
40
|
+
return self
|
|
41
|
+
for key, value in list(extras.items()):
|
|
42
|
+
extras[key] = type(self).coerce(value)
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
def __bool__(self) -> bool:
|
|
46
|
+
return bool(self.model_dump())
|
|
47
|
+
|
|
48
|
+
def __getitem__(self, key: str) -> Any:
|
|
49
|
+
try:
|
|
50
|
+
return getattr(self, key)
|
|
51
|
+
except AttributeError as exc:
|
|
52
|
+
raise KeyError(key) from exc
|
|
53
|
+
|
|
54
|
+
def __contains__(self, key: object) -> bool:
|
|
55
|
+
return isinstance(key, str) and key in self.model_dump()
|
corekit/utils/__init__.py
CHANGED
|
@@ -1,15 +1,34 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Small, dependency-free helpers.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
Five groups: flexible parsers with caller-controlled fallbacks (``parse_*``),
|
|
5
|
+
total conversions that never raise (``safe_*`` wrappers around those parsers),
|
|
6
|
+
string assembly for human-readable output, identifier generation, and
|
|
7
|
+
nested-dictionary lookup. Plus timezone-aware time helpers and the placeholder
|
|
8
|
+
callbacks (``void``, ``raise_exc``, ``true_validator``, ``false_validator``)
|
|
9
|
+
that callbacks-by-configuration need.
|
|
9
10
|
"""
|
|
10
11
|
|
|
11
|
-
from corekit.utils.coercion import
|
|
12
|
-
|
|
12
|
+
from corekit.utils.coercion import (
|
|
13
|
+
safe_dict,
|
|
14
|
+
safe_float,
|
|
15
|
+
safe_int,
|
|
16
|
+
safe_list,
|
|
17
|
+
safe_set,
|
|
18
|
+
safe_string,
|
|
19
|
+
safe_tuple,
|
|
20
|
+
)
|
|
21
|
+
from corekit.utils.collections import (
|
|
22
|
+
UNSET,
|
|
23
|
+
MultiMatch,
|
|
24
|
+
aiterator,
|
|
25
|
+
attr_or_key,
|
|
26
|
+
iterator,
|
|
27
|
+
keygetter,
|
|
28
|
+
kv_string,
|
|
29
|
+
repeated_get,
|
|
30
|
+
split_list,
|
|
31
|
+
)
|
|
13
32
|
from corekit.utils.ids import (
|
|
14
33
|
SHORTCODE_CHARS,
|
|
15
34
|
generate_session_token,
|
|
@@ -17,6 +36,16 @@ from corekit.utils.ids import (
|
|
|
17
36
|
generate_shortcodes,
|
|
18
37
|
generate_uuid,
|
|
19
38
|
)
|
|
39
|
+
from corekit.utils.parsers import (
|
|
40
|
+
ParsedValue,
|
|
41
|
+
parse_dict,
|
|
42
|
+
parse_float,
|
|
43
|
+
parse_int,
|
|
44
|
+
parse_list,
|
|
45
|
+
parse_set,
|
|
46
|
+
parse_string,
|
|
47
|
+
parse_tuple,
|
|
48
|
+
)
|
|
20
49
|
from corekit.utils.payload import Payload, decode_payload, encode_payload
|
|
21
50
|
from corekit.utils.raise_exc import raise_exc
|
|
22
51
|
from corekit.utils.text import join_lines, list_to_english, sanitize_filename, truncate
|
|
@@ -28,7 +57,9 @@ __all__ = [
|
|
|
28
57
|
"SHORTCODE_CHARS",
|
|
29
58
|
"UNSET",
|
|
30
59
|
"MultiMatch",
|
|
60
|
+
"ParsedValue",
|
|
31
61
|
"Payload",
|
|
62
|
+
"aiterator",
|
|
32
63
|
"attr_or_key",
|
|
33
64
|
"decode_payload",
|
|
34
65
|
"encode_payload",
|
|
@@ -38,16 +69,26 @@ __all__ = [
|
|
|
38
69
|
"generate_shortcodes",
|
|
39
70
|
"generate_uuid",
|
|
40
71
|
"isoformat_now",
|
|
72
|
+
"iterator",
|
|
41
73
|
"join_lines",
|
|
42
74
|
"keygetter",
|
|
75
|
+
"kv_string",
|
|
43
76
|
"list_to_english",
|
|
77
|
+
"parse_dict",
|
|
44
78
|
"parse_docker_timestamp",
|
|
79
|
+
"parse_float",
|
|
80
|
+
"parse_int",
|
|
81
|
+
"parse_list",
|
|
82
|
+
"parse_set",
|
|
83
|
+
"parse_string",
|
|
84
|
+
"parse_tuple",
|
|
45
85
|
"raise_exc",
|
|
46
86
|
"repeated_get",
|
|
47
87
|
"safe_dict",
|
|
48
88
|
"safe_float",
|
|
49
89
|
"safe_int",
|
|
50
90
|
"safe_list",
|
|
91
|
+
"safe_set",
|
|
51
92
|
"safe_string",
|
|
52
93
|
"safe_tuple",
|
|
53
94
|
"sanitize_filename",
|