krons 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.
- kronos/__init__.py +0 -0
- kronos/core/__init__.py +145 -0
- kronos/core/broadcaster.py +116 -0
- kronos/core/element.py +225 -0
- kronos/core/event.py +316 -0
- kronos/core/eventbus.py +116 -0
- kronos/core/flow.py +356 -0
- kronos/core/graph.py +442 -0
- kronos/core/node.py +982 -0
- kronos/core/pile.py +575 -0
- kronos/core/processor.py +494 -0
- kronos/core/progression.py +296 -0
- kronos/enforcement/__init__.py +57 -0
- kronos/enforcement/common/__init__.py +34 -0
- kronos/enforcement/common/boolean.py +85 -0
- kronos/enforcement/common/choice.py +97 -0
- kronos/enforcement/common/mapping.py +118 -0
- kronos/enforcement/common/model.py +102 -0
- kronos/enforcement/common/number.py +98 -0
- kronos/enforcement/common/string.py +140 -0
- kronos/enforcement/context.py +129 -0
- kronos/enforcement/policy.py +80 -0
- kronos/enforcement/registry.py +153 -0
- kronos/enforcement/rule.py +312 -0
- kronos/enforcement/service.py +370 -0
- kronos/enforcement/validator.py +198 -0
- kronos/errors.py +146 -0
- kronos/operations/__init__.py +32 -0
- kronos/operations/builder.py +228 -0
- kronos/operations/flow.py +398 -0
- kronos/operations/node.py +101 -0
- kronos/operations/registry.py +92 -0
- kronos/protocols.py +414 -0
- kronos/py.typed +0 -0
- kronos/services/__init__.py +81 -0
- kronos/services/backend.py +286 -0
- kronos/services/endpoint.py +608 -0
- kronos/services/hook.py +471 -0
- kronos/services/imodel.py +465 -0
- kronos/services/registry.py +115 -0
- kronos/services/utilities/__init__.py +36 -0
- kronos/services/utilities/header_factory.py +87 -0
- kronos/services/utilities/rate_limited_executor.py +271 -0
- kronos/services/utilities/rate_limiter.py +180 -0
- kronos/services/utilities/resilience.py +414 -0
- kronos/session/__init__.py +41 -0
- kronos/session/exchange.py +258 -0
- kronos/session/message.py +60 -0
- kronos/session/session.py +411 -0
- kronos/specs/__init__.py +25 -0
- kronos/specs/adapters/__init__.py +0 -0
- kronos/specs/adapters/_utils.py +45 -0
- kronos/specs/adapters/dataclass_field.py +246 -0
- kronos/specs/adapters/factory.py +56 -0
- kronos/specs/adapters/pydantic_adapter.py +309 -0
- kronos/specs/adapters/sql_ddl.py +946 -0
- kronos/specs/catalog/__init__.py +36 -0
- kronos/specs/catalog/_audit.py +39 -0
- kronos/specs/catalog/_common.py +43 -0
- kronos/specs/catalog/_content.py +59 -0
- kronos/specs/catalog/_enforcement.py +70 -0
- kronos/specs/factory.py +120 -0
- kronos/specs/operable.py +314 -0
- kronos/specs/phrase.py +405 -0
- kronos/specs/protocol.py +140 -0
- kronos/specs/spec.py +506 -0
- kronos/types/__init__.py +60 -0
- kronos/types/_sentinel.py +311 -0
- kronos/types/base.py +369 -0
- kronos/types/db_types.py +260 -0
- kronos/types/identity.py +66 -0
- kronos/utils/__init__.py +40 -0
- kronos/utils/_hash.py +234 -0
- kronos/utils/_json_dump.py +392 -0
- kronos/utils/_lazy_init.py +63 -0
- kronos/utils/_to_list.py +165 -0
- kronos/utils/_to_num.py +85 -0
- kronos/utils/_utils.py +375 -0
- kronos/utils/concurrency/__init__.py +205 -0
- kronos/utils/concurrency/_async_call.py +333 -0
- kronos/utils/concurrency/_cancel.py +122 -0
- kronos/utils/concurrency/_errors.py +96 -0
- kronos/utils/concurrency/_patterns.py +363 -0
- kronos/utils/concurrency/_primitives.py +328 -0
- kronos/utils/concurrency/_priority_queue.py +135 -0
- kronos/utils/concurrency/_resource_tracker.py +110 -0
- kronos/utils/concurrency/_run_async.py +67 -0
- kronos/utils/concurrency/_task.py +95 -0
- kronos/utils/concurrency/_utils.py +79 -0
- kronos/utils/fuzzy/__init__.py +14 -0
- kronos/utils/fuzzy/_extract_json.py +90 -0
- kronos/utils/fuzzy/_fuzzy_json.py +288 -0
- kronos/utils/fuzzy/_fuzzy_match.py +149 -0
- kronos/utils/fuzzy/_string_similarity.py +187 -0
- kronos/utils/fuzzy/_to_dict.py +396 -0
- kronos/utils/sql/__init__.py +13 -0
- kronos/utils/sql/_sql_validation.py +142 -0
- krons-0.1.0.dist-info/METADATA +70 -0
- krons-0.1.0.dist-info/RECORD +101 -0
- krons-0.1.0.dist-info/WHEEL +4 -0
- krons-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from pydantic import Field, field_serializer, field_validator
|
|
9
|
+
|
|
10
|
+
from kronos.core import Element, Executor
|
|
11
|
+
from kronos.protocols import Invocable, implements
|
|
12
|
+
from kronos.utils.concurrency import sleep
|
|
13
|
+
|
|
14
|
+
from .backend import ServiceBackend
|
|
15
|
+
from .endpoint import Endpoint
|
|
16
|
+
from .hook import HookRegistry
|
|
17
|
+
from .utilities.rate_limited_executor import RateLimitedExecutor
|
|
18
|
+
from .utilities.rate_limiter import RateLimitConfig, TokenBucket
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from .backend import Calling
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = ("iModel",)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@implements(Invocable)
|
|
28
|
+
class iModel(Element): # noqa: N801
|
|
29
|
+
"""Unified service interface wrapping ServiceBackend with rate limiting and hooks.
|
|
30
|
+
|
|
31
|
+
Combines ServiceBackend (API abstraction) with optional:
|
|
32
|
+
- Rate limiting: TokenBucket (simple) or Executor (event-driven)
|
|
33
|
+
- Hook registry: Lifecycle callbacks at PreEventCreate/PreInvocation/PostInvocation
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
backend: ServiceBackend instance (e.g., Endpoint for HTTP APIs).
|
|
37
|
+
rate_limiter: Optional TokenBucket for simple blocking rate limits.
|
|
38
|
+
executor: Optional Executor for event-driven processing with rate limiting.
|
|
39
|
+
hook_registry: Optional HookRegistry for invocation lifecycle callbacks.
|
|
40
|
+
provider_metadata: Provider-specific state (e.g., Claude Code session_id).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
_EXECUTOR_POLL_TIMEOUT_ITERATIONS = 100
|
|
44
|
+
_EXECUTOR_POLL_SLEEP_INTERVAL = 0.1
|
|
45
|
+
|
|
46
|
+
backend: ServiceBackend | None = Field(
|
|
47
|
+
None,
|
|
48
|
+
description="ServiceBackend instance (e.g., Endpoint)",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
rate_limiter: TokenBucket | None = Field(
|
|
52
|
+
None,
|
|
53
|
+
description="Optional TokenBucket rate limiter (simple blocking)",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
executor: Executor | None = Field(
|
|
57
|
+
None,
|
|
58
|
+
description="Optional Executor for event-driven processing with rate limiting",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
hook_registry: HookRegistry | None = Field(
|
|
62
|
+
None,
|
|
63
|
+
description="Optional HookRegistry for invocation lifecycle hooks",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
provider_metadata: dict[str, Any] = Field(
|
|
67
|
+
default_factory=dict,
|
|
68
|
+
description="Provider-specific metadata (e.g., Claude Code session_id for context continuation)",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
backend: ServiceBackend,
|
|
74
|
+
rate_limiter: TokenBucket | None = None,
|
|
75
|
+
executor: Executor | None = None,
|
|
76
|
+
hook_registry: HookRegistry | None = None,
|
|
77
|
+
queue_capacity: int = 100,
|
|
78
|
+
capacity_refresh_time: float = 60,
|
|
79
|
+
limit_requests: int | None = None,
|
|
80
|
+
):
|
|
81
|
+
"""Initialize iModel with ServiceBackend.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
backend: ServiceBackend instance (required).
|
|
85
|
+
rate_limiter: TokenBucket for simple blocking rate limits.
|
|
86
|
+
executor: Executor for event-driven processing.
|
|
87
|
+
hook_registry: HookRegistry for lifecycle callbacks.
|
|
88
|
+
queue_capacity: Event queue size for auto-constructed executor.
|
|
89
|
+
capacity_refresh_time: Seconds for rate limit bucket refill.
|
|
90
|
+
limit_requests: If set without executor, auto-constructs RateLimitedExecutor.
|
|
91
|
+
"""
|
|
92
|
+
if executor is None and limit_requests:
|
|
93
|
+
request_bucket = TokenBucket(
|
|
94
|
+
RateLimitConfig(
|
|
95
|
+
capacity=limit_requests,
|
|
96
|
+
refill_rate=limit_requests / capacity_refresh_time,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
executor = RateLimitedExecutor(
|
|
101
|
+
processor_config={
|
|
102
|
+
"queue_capacity": queue_capacity,
|
|
103
|
+
"capacity_refresh_time": capacity_refresh_time,
|
|
104
|
+
"request_bucket": request_bucket,
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
super().__init__(
|
|
109
|
+
backend=backend,
|
|
110
|
+
rate_limiter=rate_limiter,
|
|
111
|
+
executor=executor,
|
|
112
|
+
hook_registry=hook_registry,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def name(self) -> str:
|
|
117
|
+
"""Service name from backend."""
|
|
118
|
+
if self.backend is None:
|
|
119
|
+
raise RuntimeError("Backend not configured")
|
|
120
|
+
return self.backend.name
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def version(self) -> str:
|
|
124
|
+
"""Service version from backend."""
|
|
125
|
+
if self.backend is None:
|
|
126
|
+
raise RuntimeError("Backend not configured")
|
|
127
|
+
return self.backend.version or ""
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def tags(self) -> set[str]:
|
|
131
|
+
"""Service tags from backend."""
|
|
132
|
+
if self.backend is None:
|
|
133
|
+
raise RuntimeError("Backend not configured")
|
|
134
|
+
return self.backend.tags
|
|
135
|
+
|
|
136
|
+
async def create_calling(
|
|
137
|
+
self,
|
|
138
|
+
timeout: float | None = None,
|
|
139
|
+
streaming: bool = False,
|
|
140
|
+
create_event_exit_hook: bool | None = None,
|
|
141
|
+
create_event_hook_timeout: float = 10.0,
|
|
142
|
+
create_event_hook_params: dict | None = None,
|
|
143
|
+
pre_invoke_exit_hook: bool | None = None,
|
|
144
|
+
pre_invoke_hook_timeout: float = 30.0,
|
|
145
|
+
pre_invoke_hook_params: dict | None = None,
|
|
146
|
+
post_invoke_exit_hook: bool | None = None,
|
|
147
|
+
post_invoke_hook_timeout: float = 30.0,
|
|
148
|
+
post_invoke_hook_params: dict | None = None,
|
|
149
|
+
**arguments: Any,
|
|
150
|
+
) -> Calling:
|
|
151
|
+
"""Create Calling instance via backend.
|
|
152
|
+
|
|
153
|
+
Calls create_payload on backend to get validated payload.
|
|
154
|
+
Attaches hook_registry to Calling if configured.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
timeout: Event timeout in seconds (enforced in Event.invoke via fail_after)
|
|
158
|
+
streaming: Whether this is a streaming request (Event.streaming attr)
|
|
159
|
+
create_event_exit_hook: Whether pre-event-create hook should trigger exit on failure (None = use default)
|
|
160
|
+
create_event_hook_timeout: Timeout for pre-event-create hook execution in seconds
|
|
161
|
+
create_event_hook_params: Optional parameters to pass to pre-event-create hook
|
|
162
|
+
pre_invoke_exit_hook: Whether pre-invoke hook should trigger exit on failure (None = use default)
|
|
163
|
+
pre_invoke_hook_timeout: Timeout for pre-invoke hook execution in seconds
|
|
164
|
+
pre_invoke_hook_params: Optional parameters to pass to pre-invoke hook
|
|
165
|
+
post_invoke_exit_hook: Whether post-invoke hook should trigger exit on failure (None = use default)
|
|
166
|
+
post_invoke_hook_timeout: Timeout for post-invoke hook execution in seconds
|
|
167
|
+
post_invoke_hook_params: Optional parameters to pass to post-invoke hook
|
|
168
|
+
**arguments: Request arguments to pass to backend
|
|
169
|
+
"""
|
|
170
|
+
from .hook import HookEvent, HookPhase
|
|
171
|
+
|
|
172
|
+
if self.backend is None:
|
|
173
|
+
raise RuntimeError("Backend not configured")
|
|
174
|
+
|
|
175
|
+
calling_type = self.backend.event_type
|
|
176
|
+
|
|
177
|
+
if self.hook_registry is not None and self.hook_registry._can_handle(
|
|
178
|
+
hp_=HookPhase.PreEventCreate
|
|
179
|
+
):
|
|
180
|
+
h_ev = HookEvent(
|
|
181
|
+
hook_phase=HookPhase.PreEventCreate,
|
|
182
|
+
event_like=calling_type,
|
|
183
|
+
registry=self.hook_registry,
|
|
184
|
+
exit=(create_event_exit_hook if create_event_exit_hook is not None else False),
|
|
185
|
+
timeout=create_event_hook_timeout,
|
|
186
|
+
streaming=False,
|
|
187
|
+
params=create_event_hook_params or {},
|
|
188
|
+
)
|
|
189
|
+
await h_ev.invoke()
|
|
190
|
+
|
|
191
|
+
if h_ev._should_exit:
|
|
192
|
+
raise h_ev._exit_cause or RuntimeError(
|
|
193
|
+
"PreEventCreate hook requested exit without a cause"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
payload = self.backend.create_payload(request=arguments)
|
|
197
|
+
calling: Calling = calling_type(
|
|
198
|
+
backend=self.backend,
|
|
199
|
+
payload=payload,
|
|
200
|
+
timeout=timeout,
|
|
201
|
+
streaming=streaming,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if self.hook_registry is not None and self.hook_registry._can_handle(
|
|
205
|
+
hp_=HookPhase.PreInvocation
|
|
206
|
+
):
|
|
207
|
+
calling.create_pre_invoke_hook(
|
|
208
|
+
hook_registry=self.hook_registry,
|
|
209
|
+
exit_hook=(pre_invoke_exit_hook if pre_invoke_exit_hook is not None else False),
|
|
210
|
+
hook_timeout=pre_invoke_hook_timeout,
|
|
211
|
+
hook_params=pre_invoke_hook_params or {},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if self.hook_registry is not None and self.hook_registry._can_handle(
|
|
215
|
+
hp_=HookPhase.PostInvocation
|
|
216
|
+
):
|
|
217
|
+
calling.create_post_invoke_hook(
|
|
218
|
+
hook_registry=self.hook_registry,
|
|
219
|
+
exit_hook=(post_invoke_exit_hook if post_invoke_exit_hook is not None else False),
|
|
220
|
+
hook_timeout=post_invoke_hook_timeout,
|
|
221
|
+
hook_params=post_invoke_hook_params or {},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if timeout is not None:
|
|
225
|
+
calling.timeout = timeout
|
|
226
|
+
if streaming:
|
|
227
|
+
calling.streaming = streaming
|
|
228
|
+
|
|
229
|
+
return calling
|
|
230
|
+
|
|
231
|
+
async def invoke(
|
|
232
|
+
self,
|
|
233
|
+
calling: Calling | None = None,
|
|
234
|
+
poll_timeout: float | None = None,
|
|
235
|
+
poll_interval: float | None = None,
|
|
236
|
+
**arguments: Any,
|
|
237
|
+
) -> Calling:
|
|
238
|
+
"""Invoke calling with optional event-driven processing.
|
|
239
|
+
|
|
240
|
+
Routes invocation based on executor presence:
|
|
241
|
+
- If executor configured: event-driven processing with rate limiting (lionagi v0 pattern)
|
|
242
|
+
- Otherwise: direct invocation with optional simple rate limiting
|
|
243
|
+
|
|
244
|
+
Hooks are handled by Calling itself during invocation.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
calling: Pre-created Calling instance. If provided, **arguments are IGNORED
|
|
248
|
+
and the calling is invoked directly. Use this when you need to configure
|
|
249
|
+
the Calling beforehand (e.g., set timeout on the Event).
|
|
250
|
+
poll_timeout: Max seconds to wait for executor completion (default: 10s).
|
|
251
|
+
For long-running LLM calls, increase this (e.g., 120s for large models).
|
|
252
|
+
poll_interval: Seconds between status checks (default: 0.1s).
|
|
253
|
+
**arguments: Request arguments passed to create_calling. IGNORED if calling provided.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Calling instance with execution results populated
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
TimeoutError: If rate limit acquisition or polling times out
|
|
260
|
+
RuntimeError: If event aborted after 3 permission denials (executor path)
|
|
261
|
+
|
|
262
|
+
Example:
|
|
263
|
+
# Standard usage - create and invoke in one call
|
|
264
|
+
calling = await imodel.invoke(model="gpt-4", messages=[...])
|
|
265
|
+
|
|
266
|
+
# Pre-created calling with custom timeout
|
|
267
|
+
calling = await imodel.create_calling(model="gpt-4", messages=[...])
|
|
268
|
+
calling.timeout = 120.0 # 2 minute timeout
|
|
269
|
+
calling = await imodel.invoke(calling=calling)
|
|
270
|
+
"""
|
|
271
|
+
if calling is None:
|
|
272
|
+
calling = await self.create_calling(**arguments)
|
|
273
|
+
|
|
274
|
+
if self.executor:
|
|
275
|
+
if self.executor.processor is None or self.executor.processor.is_stopped():
|
|
276
|
+
await self.executor.start()
|
|
277
|
+
|
|
278
|
+
await self.executor.append(calling)
|
|
279
|
+
await self.executor.forward()
|
|
280
|
+
|
|
281
|
+
# Poll for completion (fast backends see ~100-200% overhead, slow backends <10%)
|
|
282
|
+
interval = poll_interval or self._EXECUTOR_POLL_SLEEP_INTERVAL
|
|
283
|
+
timeout_seconds = poll_timeout or (
|
|
284
|
+
self._EXECUTOR_POLL_TIMEOUT_ITERATIONS * self._EXECUTOR_POLL_SLEEP_INTERVAL
|
|
285
|
+
)
|
|
286
|
+
max_iterations = int(timeout_seconds / interval)
|
|
287
|
+
ctr = 0
|
|
288
|
+
|
|
289
|
+
while calling.execution.status.value in ["pending", "processing"]:
|
|
290
|
+
if ctr > max_iterations:
|
|
291
|
+
raise TimeoutError(
|
|
292
|
+
f"Event processing timeout after {timeout_seconds:.1f}s: {calling.id}"
|
|
293
|
+
)
|
|
294
|
+
await self.executor.forward()
|
|
295
|
+
ctr += 1
|
|
296
|
+
await sleep(interval)
|
|
297
|
+
|
|
298
|
+
if calling.execution.status.value == "aborted":
|
|
299
|
+
raise RuntimeError(
|
|
300
|
+
f"Event aborted after 3 permission denials (rate limited): {calling.id}"
|
|
301
|
+
)
|
|
302
|
+
elif calling.execution.status.value == "failed":
|
|
303
|
+
raise calling.execution.error or RuntimeError(f"Event failed: {calling.id}")
|
|
304
|
+
|
|
305
|
+
self._store_claude_code_session_id(calling)
|
|
306
|
+
return calling
|
|
307
|
+
|
|
308
|
+
else:
|
|
309
|
+
if self.rate_limiter:
|
|
310
|
+
acquired = await self.rate_limiter.acquire(timeout=30.0)
|
|
311
|
+
if not acquired:
|
|
312
|
+
raise TimeoutError("Rate limit acquisition timeout (30s)")
|
|
313
|
+
|
|
314
|
+
await calling.invoke()
|
|
315
|
+
self._store_claude_code_session_id(calling)
|
|
316
|
+
return calling
|
|
317
|
+
|
|
318
|
+
def _store_claude_code_session_id(self, calling: Calling) -> None:
|
|
319
|
+
"""Extract and store Claude Code session_id for context continuation."""
|
|
320
|
+
from kronos.types import is_sentinel
|
|
321
|
+
|
|
322
|
+
from .backend import NormalizedResponse
|
|
323
|
+
|
|
324
|
+
if (
|
|
325
|
+
isinstance(self.backend, Endpoint)
|
|
326
|
+
and self.backend.config.provider == "claude_code"
|
|
327
|
+
and not is_sentinel(calling.execution.response)
|
|
328
|
+
):
|
|
329
|
+
response = calling.execution.response
|
|
330
|
+
# session_id is in response metadata
|
|
331
|
+
if isinstance(response, NormalizedResponse) and response.metadata:
|
|
332
|
+
session_id = response.metadata.get("session_id")
|
|
333
|
+
if session_id:
|
|
334
|
+
self.provider_metadata["session_id"] = session_id
|
|
335
|
+
|
|
336
|
+
@field_serializer("backend")
|
|
337
|
+
def _serialize_backend(self, backend: ServiceBackend) -> dict[str, Any] | None:
|
|
338
|
+
"""Serialize backend to dict with kron_class for polymorphic restoration."""
|
|
339
|
+
if backend is None:
|
|
340
|
+
return None
|
|
341
|
+
backend_dict = backend.model_dump()
|
|
342
|
+
if "metadata" not in backend_dict:
|
|
343
|
+
backend_dict["metadata"] = {}
|
|
344
|
+
backend_dict["metadata"]["kron_class"] = backend.__class__.class_name(full=True)
|
|
345
|
+
return backend_dict
|
|
346
|
+
|
|
347
|
+
@field_serializer("rate_limiter")
|
|
348
|
+
def _serialize_rate_limiter(self, v: TokenBucket | None) -> dict[str, Any] | None:
|
|
349
|
+
if v is None:
|
|
350
|
+
return None
|
|
351
|
+
return v.to_dict()
|
|
352
|
+
|
|
353
|
+
@field_serializer("executor")
|
|
354
|
+
def _serialize_executor(self, executor: Executor | None) -> dict[str, Any] | None:
|
|
355
|
+
"""Serialize executor config (ephemeral state lost, fresh capacity on restore)."""
|
|
356
|
+
if executor is None:
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
if isinstance(executor, RateLimitedExecutor):
|
|
360
|
+
config = {**executor.processor_config}
|
|
361
|
+
if "request_bucket" in config and config["request_bucket"] is not None:
|
|
362
|
+
bucket = config["request_bucket"]
|
|
363
|
+
if isinstance(bucket, TokenBucket):
|
|
364
|
+
config["request_bucket"] = bucket.to_dict()
|
|
365
|
+
return config
|
|
366
|
+
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
@field_validator("rate_limiter", mode="before")
|
|
370
|
+
@classmethod
|
|
371
|
+
def _deserialize_rate_limiter(cls, v: Any) -> TokenBucket | None:
|
|
372
|
+
"""Reconstruct TokenBucket from RateLimitConfig dict."""
|
|
373
|
+
if v is None:
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
if isinstance(v, TokenBucket):
|
|
377
|
+
return v
|
|
378
|
+
|
|
379
|
+
if not isinstance(v, dict):
|
|
380
|
+
raise ValueError("rate_limiter must be a dict or TokenBucket instance")
|
|
381
|
+
|
|
382
|
+
config = RateLimitConfig(**v)
|
|
383
|
+
return TokenBucket(config)
|
|
384
|
+
|
|
385
|
+
@field_validator("backend", mode="before")
|
|
386
|
+
@classmethod
|
|
387
|
+
def _deserialize_backend(cls, v: Any) -> ServiceBackend:
|
|
388
|
+
"""Reconstruct backend via Element polymorphic deserialization."""
|
|
389
|
+
if v is None:
|
|
390
|
+
raise ValueError("backend is required")
|
|
391
|
+
|
|
392
|
+
if isinstance(v, ServiceBackend):
|
|
393
|
+
return v
|
|
394
|
+
|
|
395
|
+
if not isinstance(v, dict):
|
|
396
|
+
raise ValueError("backend must be a dict or ServiceBackend instance")
|
|
397
|
+
|
|
398
|
+
from kronos.core import Element
|
|
399
|
+
|
|
400
|
+
backend = Element.from_dict(v)
|
|
401
|
+
|
|
402
|
+
if not isinstance(backend, ServiceBackend):
|
|
403
|
+
raise ValueError(
|
|
404
|
+
f"Deserialized backend must be ServiceBackend subclass, got: {type(backend).__name__}"
|
|
405
|
+
)
|
|
406
|
+
return backend
|
|
407
|
+
|
|
408
|
+
@field_validator("executor", mode="before")
|
|
409
|
+
@classmethod
|
|
410
|
+
def _deserialize_executor(cls, v: Any) -> Executor | None:
|
|
411
|
+
"""Reconstruct executor from config dict (TokenBuckets get fresh capacity)."""
|
|
412
|
+
if v is None:
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
if isinstance(v, Executor):
|
|
416
|
+
return v
|
|
417
|
+
|
|
418
|
+
if not isinstance(v, dict):
|
|
419
|
+
raise ValueError("executor must be a dict or Executor instance")
|
|
420
|
+
|
|
421
|
+
config = {**v}
|
|
422
|
+
if "request_bucket" in config and isinstance(config["request_bucket"], dict):
|
|
423
|
+
config["request_bucket"] = TokenBucket(RateLimitConfig(**config["request_bucket"]))
|
|
424
|
+
if "token_bucket" in config and isinstance(config["token_bucket"], dict):
|
|
425
|
+
config["token_bucket"] = TokenBucket(RateLimitConfig(**config["token_bucket"]))
|
|
426
|
+
|
|
427
|
+
return RateLimitedExecutor(processor_config=config)
|
|
428
|
+
|
|
429
|
+
def _to_dict(self, **kwargs: Any) -> dict[str, Any]:
|
|
430
|
+
"""Serialize to dict, excluding id/created_at for fresh identity on reconstruction."""
|
|
431
|
+
kwargs.setdefault("exclude", set()).update({"id", "created_at"})
|
|
432
|
+
return super()._to_dict(**kwargs)
|
|
433
|
+
|
|
434
|
+
def __repr__(self) -> str:
|
|
435
|
+
"""String representation."""
|
|
436
|
+
if self.backend is None:
|
|
437
|
+
return "iModel(backend=None)"
|
|
438
|
+
return f"iModel(backend={self.backend.name}, version={self.backend.version})"
|
|
439
|
+
|
|
440
|
+
async def __aenter__(self) -> iModel:
|
|
441
|
+
"""Enter async context, starting executor if configured."""
|
|
442
|
+
if self.executor is not None and (
|
|
443
|
+
self.executor.processor is None or self.executor.processor.is_stopped()
|
|
444
|
+
):
|
|
445
|
+
await self.executor.start()
|
|
446
|
+
return self
|
|
447
|
+
|
|
448
|
+
async def __aexit__(
|
|
449
|
+
self,
|
|
450
|
+
_exc_type: type[BaseException] | None,
|
|
451
|
+
_exc_val: BaseException | None,
|
|
452
|
+
_exc_tb: Any,
|
|
453
|
+
) -> bool:
|
|
454
|
+
"""Exit async context, stopping executor if running.
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
False to propagate any exceptions (never suppresses)
|
|
458
|
+
"""
|
|
459
|
+
if (
|
|
460
|
+
self.executor is not None
|
|
461
|
+
and self.executor.processor is not None
|
|
462
|
+
and not self.executor.processor.is_stopped()
|
|
463
|
+
):
|
|
464
|
+
await self.executor.stop()
|
|
465
|
+
return False
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from kronos.core import Pile
|
|
10
|
+
from kronos.types import Undefined, UndefinedType, is_sentinel
|
|
11
|
+
|
|
12
|
+
from .imodel import iModel
|
|
13
|
+
|
|
14
|
+
__all__ = ("ServiceRegistry",)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ServiceRegistry:
|
|
18
|
+
"""Service registry managing iModel instances with O(1) name-based lookup.
|
|
19
|
+
|
|
20
|
+
Provides type-safe storage via Pile[iModel] with name-based indexing.
|
|
21
|
+
Services must have unique names; duplicates raise ValueError unless update=True.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
>>> registry = ServiceRegistry()
|
|
25
|
+
>>> registry.register(iModel(backend=my_endpoint))
|
|
26
|
+
>>> model = registry.get("my_service")
|
|
27
|
+
>>> tagged = registry.list_by_tag("api")
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self):
|
|
31
|
+
"""Initialize empty registry with Pile storage and name index."""
|
|
32
|
+
from .imodel import iModel
|
|
33
|
+
|
|
34
|
+
self._pile: Pile[iModel] = Pile(item_type=iModel)
|
|
35
|
+
self._name_index: dict[str, UUID] = {}
|
|
36
|
+
|
|
37
|
+
def register(self, model: iModel, update: bool = False) -> UUID:
|
|
38
|
+
"""Register iModel instance by name.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
model: iModel instance to register.
|
|
42
|
+
update: If True, replaces existing service with same name.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
UUID of registered service.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ValueError: If service name exists and update=False.
|
|
49
|
+
"""
|
|
50
|
+
if model.name in self._name_index:
|
|
51
|
+
if not update:
|
|
52
|
+
raise ValueError(f"Service '{model.name}' already registered")
|
|
53
|
+
# Update: remove old, add new
|
|
54
|
+
old_uid = self._name_index[model.name]
|
|
55
|
+
self._pile.remove(old_uid)
|
|
56
|
+
|
|
57
|
+
self._pile.add(model)
|
|
58
|
+
self._name_index[model.name] = model.id
|
|
59
|
+
|
|
60
|
+
return model.id
|
|
61
|
+
|
|
62
|
+
def unregister(self, name: str) -> iModel:
|
|
63
|
+
"""Remove and return service by name. Raises KeyError if not found."""
|
|
64
|
+
if name not in self._name_index:
|
|
65
|
+
raise KeyError(f"Service '{name}' not found")
|
|
66
|
+
|
|
67
|
+
uid = self._name_index.pop(name)
|
|
68
|
+
return self._pile.remove(uid)
|
|
69
|
+
|
|
70
|
+
def get(self, name: str | UUID | iModel, default: Any | UndefinedType = Undefined) -> iModel:
|
|
71
|
+
"""Get service by name, UUID, or return iModel passthrough. Raises KeyError if not found."""
|
|
72
|
+
if isinstance(name, UUID):
|
|
73
|
+
return self._pile[name]
|
|
74
|
+
if isinstance(name, iModel):
|
|
75
|
+
return name
|
|
76
|
+
if name not in self._name_index:
|
|
77
|
+
if not is_sentinel(default):
|
|
78
|
+
return default
|
|
79
|
+
raise KeyError(f"Service '{name}' not found")
|
|
80
|
+
|
|
81
|
+
uid = self._name_index[name]
|
|
82
|
+
return self._pile[uid]
|
|
83
|
+
|
|
84
|
+
def has(self, name: str) -> bool:
|
|
85
|
+
"""Check if service exists."""
|
|
86
|
+
return name in self._name_index
|
|
87
|
+
|
|
88
|
+
def list_names(self) -> list[str]:
|
|
89
|
+
"""List all registered service names."""
|
|
90
|
+
return list(self._name_index.keys())
|
|
91
|
+
|
|
92
|
+
def list_by_tag(self, tag: str) -> Pile[iModel]:
|
|
93
|
+
"""Filter services by tag, returns Pile of matching iModels."""
|
|
94
|
+
return self._pile[lambda m: tag in m.tags]
|
|
95
|
+
|
|
96
|
+
def count(self) -> int:
|
|
97
|
+
"""Count registered services."""
|
|
98
|
+
return len(self._pile)
|
|
99
|
+
|
|
100
|
+
def clear(self) -> None:
|
|
101
|
+
"""Remove all registered services."""
|
|
102
|
+
self._pile.clear()
|
|
103
|
+
self._name_index.clear()
|
|
104
|
+
|
|
105
|
+
def __len__(self) -> int:
|
|
106
|
+
"""Return number of registered services."""
|
|
107
|
+
return len(self._pile)
|
|
108
|
+
|
|
109
|
+
def __contains__(self, name: str) -> bool:
|
|
110
|
+
"""Check if service exists (supports `name in registry`)."""
|
|
111
|
+
return name in self._name_index
|
|
112
|
+
|
|
113
|
+
def __repr__(self) -> str:
|
|
114
|
+
"""String representation."""
|
|
115
|
+
return f"ServiceRegistry(count={len(self)})"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Service utilities: rate limiting and resilience patterns.
|
|
5
|
+
|
|
6
|
+
Exports:
|
|
7
|
+
Rate limiting:
|
|
8
|
+
- RateLimitConfig: Token bucket configuration
|
|
9
|
+
- TokenBucket: Rate limiter with continuous refill
|
|
10
|
+
|
|
11
|
+
Resilience:
|
|
12
|
+
- CircuitBreaker: Fail-fast with state machine (CLOSED/OPEN/HALF_OPEN)
|
|
13
|
+
- CircuitBreakerOpenError: Raised when circuit is open
|
|
14
|
+
- CircuitState: Circuit state enum
|
|
15
|
+
- RetryConfig: Retry policy configuration
|
|
16
|
+
- retry_with_backoff: Async retry with exponential backoff + jitter
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .rate_limiter import RateLimitConfig, TokenBucket
|
|
20
|
+
from .resilience import (
|
|
21
|
+
CircuitBreaker,
|
|
22
|
+
CircuitBreakerOpenError,
|
|
23
|
+
CircuitState,
|
|
24
|
+
RetryConfig,
|
|
25
|
+
retry_with_backoff,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = (
|
|
29
|
+
"CircuitBreaker",
|
|
30
|
+
"CircuitBreakerOpenError",
|
|
31
|
+
"CircuitState",
|
|
32
|
+
"RateLimitConfig",
|
|
33
|
+
"RetryConfig",
|
|
34
|
+
"TokenBucket",
|
|
35
|
+
"retry_with_backoff",
|
|
36
|
+
)
|