krons 0.1.0__py3-none-any.whl → 0.2.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.
- krons/__init__.py +49 -0
- krons/agent/__init__.py +144 -0
- krons/agent/mcps/__init__.py +14 -0
- krons/agent/mcps/loader.py +287 -0
- krons/agent/mcps/wrapper.py +799 -0
- krons/agent/message/__init__.py +20 -0
- krons/agent/message/action.py +69 -0
- krons/agent/message/assistant.py +52 -0
- krons/agent/message/common.py +49 -0
- krons/agent/message/instruction.py +130 -0
- krons/agent/message/prepare_msg.py +187 -0
- krons/agent/message/role.py +53 -0
- krons/agent/message/system.py +53 -0
- krons/agent/operations/__init__.py +82 -0
- krons/agent/operations/act.py +100 -0
- krons/agent/operations/generate.py +145 -0
- krons/agent/operations/llm_reparse.py +89 -0
- krons/agent/operations/operate.py +247 -0
- krons/agent/operations/parse.py +243 -0
- krons/agent/operations/react.py +286 -0
- krons/agent/operations/specs.py +235 -0
- krons/agent/operations/structure.py +151 -0
- krons/agent/operations/utils.py +79 -0
- krons/agent/providers/__init__.py +17 -0
- krons/agent/providers/anthropic_messages.py +146 -0
- krons/agent/providers/claude_code.py +276 -0
- krons/agent/providers/gemini.py +268 -0
- krons/agent/providers/match.py +75 -0
- krons/agent/providers/oai_chat.py +174 -0
- krons/agent/third_party/__init__.py +2 -0
- krons/agent/third_party/anthropic_models.py +154 -0
- krons/agent/third_party/claude_code.py +682 -0
- krons/agent/third_party/gemini_models.py +508 -0
- krons/agent/third_party/openai_models.py +295 -0
- krons/agent/tool.py +291 -0
- krons/core/__init__.py +127 -0
- krons/core/base/__init__.py +121 -0
- {kronos/core → krons/core/base}/broadcaster.py +7 -3
- {kronos/core → krons/core/base}/element.py +15 -7
- {kronos/core → krons/core/base}/event.py +41 -8
- {kronos/core → krons/core/base}/eventbus.py +4 -2
- {kronos/core → krons/core/base}/flow.py +14 -7
- {kronos/core → krons/core/base}/graph.py +27 -11
- {kronos/core → krons/core/base}/node.py +47 -22
- {kronos/core → krons/core/base}/pile.py +26 -12
- {kronos/core → krons/core/base}/processor.py +23 -9
- {kronos/core → krons/core/base}/progression.py +5 -3
- {kronos → krons/core}/specs/__init__.py +0 -5
- {kronos → krons/core}/specs/adapters/dataclass_field.py +16 -8
- {kronos → krons/core}/specs/adapters/pydantic_adapter.py +11 -5
- {kronos → krons/core}/specs/adapters/sql_ddl.py +16 -10
- {kronos → krons/core}/specs/catalog/__init__.py +2 -2
- {kronos → krons/core}/specs/catalog/_audit.py +3 -3
- {kronos → krons/core}/specs/catalog/_common.py +2 -2
- {kronos → krons/core}/specs/catalog/_content.py +5 -5
- {kronos → krons/core}/specs/catalog/_enforcement.py +4 -4
- {kronos → krons/core}/specs/factory.py +7 -7
- {kronos → krons/core}/specs/operable.py +9 -3
- {kronos → krons/core}/specs/protocol.py +4 -2
- {kronos → krons/core}/specs/spec.py +25 -13
- {kronos → krons/core}/types/base.py +7 -5
- {kronos → krons/core}/types/db_types.py +2 -2
- {kronos → krons/core}/types/identity.py +1 -1
- {kronos → krons}/errors.py +13 -13
- {kronos → krons}/protocols.py +9 -4
- krons/resource/__init__.py +89 -0
- {kronos/services → krons/resource}/backend.py +50 -24
- {kronos/services → krons/resource}/endpoint.py +28 -14
- {kronos/services → krons/resource}/hook.py +22 -9
- {kronos/services → krons/resource}/imodel.py +50 -32
- {kronos/services → krons/resource}/registry.py +27 -25
- {kronos/services → krons/resource}/utilities/rate_limited_executor.py +10 -6
- {kronos/services → krons/resource}/utilities/rate_limiter.py +4 -2
- {kronos/services → krons/resource}/utilities/resilience.py +17 -7
- krons/resource/utilities/token_calculator.py +185 -0
- {kronos → krons}/session/__init__.py +12 -17
- krons/session/constraints.py +70 -0
- {kronos → krons}/session/exchange.py +14 -6
- {kronos → krons}/session/message.py +4 -2
- krons/session/registry.py +35 -0
- {kronos → krons}/session/session.py +165 -174
- krons/utils/__init__.py +85 -0
- krons/utils/_function_arg_parser.py +99 -0
- krons/utils/_pythonic_function_call.py +249 -0
- {kronos → krons}/utils/_to_list.py +9 -3
- {kronos → krons}/utils/_utils.py +9 -5
- {kronos → krons}/utils/concurrency/__init__.py +38 -38
- {kronos → krons}/utils/concurrency/_async_call.py +6 -4
- {kronos → krons}/utils/concurrency/_errors.py +3 -1
- {kronos → krons}/utils/concurrency/_patterns.py +3 -1
- {kronos → krons}/utils/concurrency/_resource_tracker.py +6 -2
- krons/utils/display.py +257 -0
- {kronos → krons}/utils/fuzzy/__init__.py +6 -1
- {kronos → krons}/utils/fuzzy/_fuzzy_match.py +14 -8
- {kronos → krons}/utils/fuzzy/_string_similarity.py +3 -1
- {kronos → krons}/utils/fuzzy/_to_dict.py +3 -1
- krons/utils/schemas/__init__.py +26 -0
- krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
- krons/utils/schemas/_formatter.py +72 -0
- krons/utils/schemas/_minimal_yaml.py +151 -0
- krons/utils/schemas/_typescript.py +153 -0
- {kronos → krons}/utils/sql/_sql_validation.py +1 -1
- krons/utils/validators/__init__.py +3 -0
- krons/utils/validators/_validate_image_url.py +56 -0
- krons/work/__init__.py +126 -0
- krons/work/engine.py +333 -0
- krons/work/form.py +305 -0
- {kronos → krons/work}/operations/__init__.py +7 -4
- {kronos → krons/work}/operations/builder.py +4 -4
- {kronos/enforcement → krons/work/operations}/context.py +37 -6
- {kronos → krons/work}/operations/flow.py +17 -9
- krons/work/operations/node.py +103 -0
- krons/work/operations/registry.py +103 -0
- {kronos/specs → krons/work}/phrase.py +131 -14
- {kronos/enforcement → krons/work}/policy.py +3 -3
- krons/work/report.py +268 -0
- krons/work/rules/__init__.py +47 -0
- {kronos/enforcement → krons/work/rules}/common/boolean.py +3 -1
- {kronos/enforcement → krons/work/rules}/common/choice.py +9 -3
- {kronos/enforcement → krons/work/rules}/common/number.py +3 -1
- {kronos/enforcement → krons/work/rules}/common/string.py +9 -3
- {kronos/enforcement → krons/work/rules}/rule.py +2 -2
- {kronos/enforcement → krons/work/rules}/validator.py +21 -6
- {kronos/enforcement → krons/work}/service.py +16 -7
- krons/work/worker.py +266 -0
- {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/METADATA +19 -5
- krons-0.2.0.dist-info/RECORD +154 -0
- kronos/core/__init__.py +0 -145
- kronos/enforcement/__init__.py +0 -57
- kronos/operations/node.py +0 -101
- kronos/operations/registry.py +0 -92
- kronos/services/__init__.py +0 -81
- kronos/specs/adapters/__init__.py +0 -0
- kronos/utils/__init__.py +0 -40
- krons-0.1.0.dist-info/RECORD +0 -101
- {kronos → krons/core/specs/adapters}/__init__.py +0 -0
- {kronos → krons/core}/specs/adapters/_utils.py +0 -0
- {kronos → krons/core}/specs/adapters/factory.py +0 -0
- {kronos → krons/core}/types/__init__.py +0 -0
- {kronos → krons/core}/types/_sentinel.py +0 -0
- {kronos → krons}/py.typed +0 -0
- {kronos/services → krons/resource}/utilities/__init__.py +0 -0
- {kronos/services → krons/resource}/utilities/header_factory.py +0 -0
- {kronos → krons}/utils/_hash.py +0 -0
- {kronos → krons}/utils/_json_dump.py +0 -0
- {kronos → krons}/utils/_lazy_init.py +0 -0
- {kronos → krons}/utils/_to_num.py +0 -0
- {kronos → krons}/utils/concurrency/_cancel.py +0 -0
- {kronos → krons}/utils/concurrency/_primitives.py +0 -0
- {kronos → krons}/utils/concurrency/_priority_queue.py +0 -0
- {kronos → krons}/utils/concurrency/_run_async.py +0 -0
- {kronos → krons}/utils/concurrency/_task.py +0 -0
- {kronos → krons}/utils/concurrency/_utils.py +0 -0
- {kronos → krons}/utils/fuzzy/_extract_json.py +0 -0
- {kronos → krons}/utils/fuzzy/_fuzzy_json.py +0 -0
- {kronos → krons}/utils/sql/__init__.py +0 -0
- {kronos/enforcement → krons/work/rules}/common/__init__.py +0 -0
- {kronos/enforcement → krons/work/rules}/common/mapping.py +0 -0
- {kronos/enforcement → krons/work/rules}/common/model.py +0 -0
- {kronos/enforcement → krons/work/rules}/registry.py +0 -0
- {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/WHEEL +0 -0
- {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -7,11 +7,11 @@ from typing import TYPE_CHECKING, Any
|
|
|
7
7
|
|
|
8
8
|
from pydantic import Field, field_serializer, field_validator
|
|
9
9
|
|
|
10
|
-
from
|
|
11
|
-
from
|
|
12
|
-
from
|
|
10
|
+
from krons.core import Element, Executor
|
|
11
|
+
from krons.protocols import Invocable, implements
|
|
12
|
+
from krons.utils.concurrency import sleep
|
|
13
13
|
|
|
14
|
-
from .backend import
|
|
14
|
+
from .backend import ResourceBackend
|
|
15
15
|
from .endpoint import Endpoint
|
|
16
16
|
from .hook import HookRegistry
|
|
17
17
|
from .utilities.rate_limited_executor import RateLimitedExecutor
|
|
@@ -26,14 +26,14 @@ __all__ = ("iModel",)
|
|
|
26
26
|
|
|
27
27
|
@implements(Invocable)
|
|
28
28
|
class iModel(Element): # noqa: N801
|
|
29
|
-
"""Unified
|
|
29
|
+
"""Unified resource interface wrapping ResourceBackend with rate limiting and hooks.
|
|
30
30
|
|
|
31
|
-
Combines
|
|
31
|
+
Combines ResourceBackend (API abstraction) with optional:
|
|
32
32
|
- Rate limiting: TokenBucket (simple) or Executor (event-driven)
|
|
33
33
|
- Hook registry: Lifecycle callbacks at PreEventCreate/PreInvocation/PostInvocation
|
|
34
34
|
|
|
35
35
|
Attributes:
|
|
36
|
-
backend:
|
|
36
|
+
backend: ResourceBackend instance (e.g., Endpoint for HTTP APIs).
|
|
37
37
|
rate_limiter: Optional TokenBucket for simple blocking rate limits.
|
|
38
38
|
executor: Optional Executor for event-driven processing with rate limiting.
|
|
39
39
|
hook_registry: Optional HookRegistry for invocation lifecycle callbacks.
|
|
@@ -43,9 +43,9 @@ class iModel(Element): # noqa: N801
|
|
|
43
43
|
_EXECUTOR_POLL_TIMEOUT_ITERATIONS = 100
|
|
44
44
|
_EXECUTOR_POLL_SLEEP_INTERVAL = 0.1
|
|
45
45
|
|
|
46
|
-
backend:
|
|
46
|
+
backend: ResourceBackend | None = Field(
|
|
47
47
|
None,
|
|
48
|
-
description="
|
|
48
|
+
description="ResourceBackend instance (e.g., Endpoint)",
|
|
49
49
|
)
|
|
50
50
|
|
|
51
51
|
rate_limiter: TokenBucket | None = Field(
|
|
@@ -70,7 +70,7 @@ class iModel(Element): # noqa: N801
|
|
|
70
70
|
|
|
71
71
|
def __init__(
|
|
72
72
|
self,
|
|
73
|
-
backend:
|
|
73
|
+
backend: ResourceBackend,
|
|
74
74
|
rate_limiter: TokenBucket | None = None,
|
|
75
75
|
executor: Executor | None = None,
|
|
76
76
|
hook_registry: HookRegistry | None = None,
|
|
@@ -78,10 +78,10 @@ class iModel(Element): # noqa: N801
|
|
|
78
78
|
capacity_refresh_time: float = 60,
|
|
79
79
|
limit_requests: int | None = None,
|
|
80
80
|
):
|
|
81
|
-
"""Initialize iModel with
|
|
81
|
+
"""Initialize iModel with ResourceBackend.
|
|
82
82
|
|
|
83
83
|
Args:
|
|
84
|
-
backend:
|
|
84
|
+
backend: ResourceBackend instance (required).
|
|
85
85
|
rate_limiter: TokenBucket for simple blocking rate limits.
|
|
86
86
|
executor: Executor for event-driven processing.
|
|
87
87
|
hook_registry: HookRegistry for lifecycle callbacks.
|
|
@@ -114,21 +114,21 @@ class iModel(Element): # noqa: N801
|
|
|
114
114
|
|
|
115
115
|
@property
|
|
116
116
|
def name(self) -> str:
|
|
117
|
-
"""
|
|
117
|
+
"""Resource name from backend."""
|
|
118
118
|
if self.backend is None:
|
|
119
119
|
raise RuntimeError("Backend not configured")
|
|
120
120
|
return self.backend.name
|
|
121
121
|
|
|
122
122
|
@property
|
|
123
123
|
def version(self) -> str:
|
|
124
|
-
"""
|
|
124
|
+
"""Resource version from backend."""
|
|
125
125
|
if self.backend is None:
|
|
126
126
|
raise RuntimeError("Backend not configured")
|
|
127
127
|
return self.backend.version or ""
|
|
128
128
|
|
|
129
129
|
@property
|
|
130
130
|
def tags(self) -> set[str]:
|
|
131
|
-
"""
|
|
131
|
+
"""Resource tags from backend."""
|
|
132
132
|
if self.backend is None:
|
|
133
133
|
raise RuntimeError("Backend not configured")
|
|
134
134
|
return self.backend.tags
|
|
@@ -181,7 +181,11 @@ class iModel(Element): # noqa: N801
|
|
|
181
181
|
hook_phase=HookPhase.PreEventCreate,
|
|
182
182
|
event_like=calling_type,
|
|
183
183
|
registry=self.hook_registry,
|
|
184
|
-
exit=(
|
|
184
|
+
exit=(
|
|
185
|
+
create_event_exit_hook
|
|
186
|
+
if create_event_exit_hook is not None
|
|
187
|
+
else False
|
|
188
|
+
),
|
|
185
189
|
timeout=create_event_hook_timeout,
|
|
186
190
|
streaming=False,
|
|
187
191
|
params=create_event_hook_params or {},
|
|
@@ -194,11 +198,12 @@ class iModel(Element): # noqa: N801
|
|
|
194
198
|
)
|
|
195
199
|
|
|
196
200
|
payload = self.backend.create_payload(request=arguments)
|
|
201
|
+
# Handle backends that return (payload, headers) tuple
|
|
202
|
+
if isinstance(payload, tuple):
|
|
203
|
+
payload, _ = payload
|
|
197
204
|
calling: Calling = calling_type(
|
|
198
205
|
backend=self.backend,
|
|
199
206
|
payload=payload,
|
|
200
|
-
timeout=timeout,
|
|
201
|
-
streaming=streaming,
|
|
202
207
|
)
|
|
203
208
|
|
|
204
209
|
if self.hook_registry is not None and self.hook_registry._can_handle(
|
|
@@ -206,7 +211,9 @@ class iModel(Element): # noqa: N801
|
|
|
206
211
|
):
|
|
207
212
|
calling.create_pre_invoke_hook(
|
|
208
213
|
hook_registry=self.hook_registry,
|
|
209
|
-
exit_hook=(
|
|
214
|
+
exit_hook=(
|
|
215
|
+
pre_invoke_exit_hook if pre_invoke_exit_hook is not None else False
|
|
216
|
+
),
|
|
210
217
|
hook_timeout=pre_invoke_hook_timeout,
|
|
211
218
|
hook_params=pre_invoke_hook_params or {},
|
|
212
219
|
)
|
|
@@ -216,7 +223,11 @@ class iModel(Element): # noqa: N801
|
|
|
216
223
|
):
|
|
217
224
|
calling.create_post_invoke_hook(
|
|
218
225
|
hook_registry=self.hook_registry,
|
|
219
|
-
exit_hook=(
|
|
226
|
+
exit_hook=(
|
|
227
|
+
post_invoke_exit_hook
|
|
228
|
+
if post_invoke_exit_hook is not None
|
|
229
|
+
else False
|
|
230
|
+
),
|
|
220
231
|
hook_timeout=post_invoke_hook_timeout,
|
|
221
232
|
hook_params=post_invoke_hook_params or {},
|
|
222
233
|
)
|
|
@@ -281,7 +292,8 @@ class iModel(Element): # noqa: N801
|
|
|
281
292
|
# Poll for completion (fast backends see ~100-200% overhead, slow backends <10%)
|
|
282
293
|
interval = poll_interval or self._EXECUTOR_POLL_SLEEP_INTERVAL
|
|
283
294
|
timeout_seconds = poll_timeout or (
|
|
284
|
-
self._EXECUTOR_POLL_TIMEOUT_ITERATIONS
|
|
295
|
+
self._EXECUTOR_POLL_TIMEOUT_ITERATIONS
|
|
296
|
+
* self._EXECUTOR_POLL_SLEEP_INTERVAL
|
|
285
297
|
)
|
|
286
298
|
max_iterations = int(timeout_seconds / interval)
|
|
287
299
|
ctr = 0
|
|
@@ -300,7 +312,9 @@ class iModel(Element): # noqa: N801
|
|
|
300
312
|
f"Event aborted after 3 permission denials (rate limited): {calling.id}"
|
|
301
313
|
)
|
|
302
314
|
elif calling.execution.status.value == "failed":
|
|
303
|
-
raise calling.execution.error or RuntimeError(
|
|
315
|
+
raise calling.execution.error or RuntimeError(
|
|
316
|
+
f"Event failed: {calling.id}"
|
|
317
|
+
)
|
|
304
318
|
|
|
305
319
|
self._store_claude_code_session_id(calling)
|
|
306
320
|
return calling
|
|
@@ -317,7 +331,7 @@ class iModel(Element): # noqa: N801
|
|
|
317
331
|
|
|
318
332
|
def _store_claude_code_session_id(self, calling: Calling) -> None:
|
|
319
333
|
"""Extract and store Claude Code session_id for context continuation."""
|
|
320
|
-
from
|
|
334
|
+
from krons.core.types import is_sentinel
|
|
321
335
|
|
|
322
336
|
from .backend import NormalizedResponse
|
|
323
337
|
|
|
@@ -334,7 +348,7 @@ class iModel(Element): # noqa: N801
|
|
|
334
348
|
self.provider_metadata["session_id"] = session_id
|
|
335
349
|
|
|
336
350
|
@field_serializer("backend")
|
|
337
|
-
def _serialize_backend(self, backend:
|
|
351
|
+
def _serialize_backend(self, backend: ResourceBackend) -> dict[str, Any] | None:
|
|
338
352
|
"""Serialize backend to dict with kron_class for polymorphic restoration."""
|
|
339
353
|
if backend is None:
|
|
340
354
|
return None
|
|
@@ -384,24 +398,24 @@ class iModel(Element): # noqa: N801
|
|
|
384
398
|
|
|
385
399
|
@field_validator("backend", mode="before")
|
|
386
400
|
@classmethod
|
|
387
|
-
def _deserialize_backend(cls, v: Any) ->
|
|
401
|
+
def _deserialize_backend(cls, v: Any) -> ResourceBackend:
|
|
388
402
|
"""Reconstruct backend via Element polymorphic deserialization."""
|
|
389
403
|
if v is None:
|
|
390
404
|
raise ValueError("backend is required")
|
|
391
405
|
|
|
392
|
-
if isinstance(v,
|
|
406
|
+
if isinstance(v, ResourceBackend):
|
|
393
407
|
return v
|
|
394
408
|
|
|
395
409
|
if not isinstance(v, dict):
|
|
396
|
-
raise ValueError("backend must be a dict or
|
|
410
|
+
raise ValueError("backend must be a dict or ResourceBackend instance")
|
|
397
411
|
|
|
398
|
-
from
|
|
412
|
+
from krons.core import Element
|
|
399
413
|
|
|
400
414
|
backend = Element.from_dict(v)
|
|
401
415
|
|
|
402
|
-
if not isinstance(backend,
|
|
416
|
+
if not isinstance(backend, ResourceBackend):
|
|
403
417
|
raise ValueError(
|
|
404
|
-
f"Deserialized backend must be
|
|
418
|
+
f"Deserialized backend must be ResourceBackend subclass, got: {type(backend).__name__}"
|
|
405
419
|
)
|
|
406
420
|
return backend
|
|
407
421
|
|
|
@@ -420,9 +434,13 @@ class iModel(Element): # noqa: N801
|
|
|
420
434
|
|
|
421
435
|
config = {**v}
|
|
422
436
|
if "request_bucket" in config and isinstance(config["request_bucket"], dict):
|
|
423
|
-
config["request_bucket"] = TokenBucket(
|
|
437
|
+
config["request_bucket"] = TokenBucket(
|
|
438
|
+
RateLimitConfig(**config["request_bucket"])
|
|
439
|
+
)
|
|
424
440
|
if "token_bucket" in config and isinstance(config["token_bucket"], dict):
|
|
425
|
-
config["token_bucket"] = TokenBucket(
|
|
441
|
+
config["token_bucket"] = TokenBucket(
|
|
442
|
+
RateLimitConfig(**config["token_bucket"])
|
|
443
|
+
)
|
|
426
444
|
|
|
427
445
|
return RateLimitedExecutor(processor_config=config)
|
|
428
446
|
|
|
@@ -6,24 +6,24 @@ from __future__ import annotations
|
|
|
6
6
|
from typing import Any
|
|
7
7
|
from uuid import UUID
|
|
8
8
|
|
|
9
|
-
from
|
|
10
|
-
from
|
|
9
|
+
from krons.core import Pile
|
|
10
|
+
from krons.core.types import Undefined, UndefinedType, is_sentinel
|
|
11
11
|
|
|
12
12
|
from .imodel import iModel
|
|
13
13
|
|
|
14
|
-
__all__ = ("
|
|
14
|
+
__all__ = ("ResourceRegistry",)
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
class
|
|
18
|
-
"""
|
|
17
|
+
class ResourceRegistry:
|
|
18
|
+
"""Resource registry managing iModel instances with O(1) name-based lookup.
|
|
19
19
|
|
|
20
20
|
Provides type-safe storage via Pile[iModel] with name-based indexing.
|
|
21
|
-
|
|
21
|
+
Resources must have unique names; duplicates raise ValueError unless update=True.
|
|
22
22
|
|
|
23
23
|
Example:
|
|
24
|
-
>>> registry =
|
|
24
|
+
>>> registry = ResourceRegistry()
|
|
25
25
|
>>> registry.register(iModel(backend=my_endpoint))
|
|
26
|
-
>>> model = registry.get("
|
|
26
|
+
>>> model = registry.get("my_resource")
|
|
27
27
|
>>> tagged = registry.list_by_tag("api")
|
|
28
28
|
"""
|
|
29
29
|
|
|
@@ -39,17 +39,17 @@ class ServiceRegistry:
|
|
|
39
39
|
|
|
40
40
|
Args:
|
|
41
41
|
model: iModel instance to register.
|
|
42
|
-
update: If True, replaces existing
|
|
42
|
+
update: If True, replaces existing resource with same name.
|
|
43
43
|
|
|
44
44
|
Returns:
|
|
45
|
-
UUID of registered
|
|
45
|
+
UUID of registered resource.
|
|
46
46
|
|
|
47
47
|
Raises:
|
|
48
|
-
ValueError: If
|
|
48
|
+
ValueError: If resource name exists and update=False.
|
|
49
49
|
"""
|
|
50
50
|
if model.name in self._name_index:
|
|
51
51
|
if not update:
|
|
52
|
-
raise ValueError(f"
|
|
52
|
+
raise ValueError(f"Resource '{model.name}' already registered")
|
|
53
53
|
# Update: remove old, add new
|
|
54
54
|
old_uid = self._name_index[model.name]
|
|
55
55
|
self._pile.remove(old_uid)
|
|
@@ -60,15 +60,17 @@ class ServiceRegistry:
|
|
|
60
60
|
return model.id
|
|
61
61
|
|
|
62
62
|
def unregister(self, name: str) -> iModel:
|
|
63
|
-
"""Remove and return
|
|
63
|
+
"""Remove and return resource by name. Raises KeyError if not found."""
|
|
64
64
|
if name not in self._name_index:
|
|
65
|
-
raise KeyError(f"
|
|
65
|
+
raise KeyError(f"Resource '{name}' not found")
|
|
66
66
|
|
|
67
67
|
uid = self._name_index.pop(name)
|
|
68
68
|
return self._pile.remove(uid)
|
|
69
69
|
|
|
70
|
-
def get(
|
|
71
|
-
|
|
70
|
+
def get(
|
|
71
|
+
self, name: str | UUID | iModel, default: Any | UndefinedType = Undefined
|
|
72
|
+
) -> iModel:
|
|
73
|
+
"""Get resource by name, UUID, or return iModel passthrough. Raises KeyError if not found."""
|
|
72
74
|
if isinstance(name, UUID):
|
|
73
75
|
return self._pile[name]
|
|
74
76
|
if isinstance(name, iModel):
|
|
@@ -76,40 +78,40 @@ class ServiceRegistry:
|
|
|
76
78
|
if name not in self._name_index:
|
|
77
79
|
if not is_sentinel(default):
|
|
78
80
|
return default
|
|
79
|
-
raise KeyError(f"
|
|
81
|
+
raise KeyError(f"Resource '{name}' not found")
|
|
80
82
|
|
|
81
83
|
uid = self._name_index[name]
|
|
82
84
|
return self._pile[uid]
|
|
83
85
|
|
|
84
86
|
def has(self, name: str) -> bool:
|
|
85
|
-
"""Check if
|
|
87
|
+
"""Check if resource exists."""
|
|
86
88
|
return name in self._name_index
|
|
87
89
|
|
|
88
90
|
def list_names(self) -> list[str]:
|
|
89
|
-
"""List all registered
|
|
91
|
+
"""List all registered resource names."""
|
|
90
92
|
return list(self._name_index.keys())
|
|
91
93
|
|
|
92
94
|
def list_by_tag(self, tag: str) -> Pile[iModel]:
|
|
93
|
-
"""Filter
|
|
95
|
+
"""Filter resources by tag, returns Pile of matching iModels."""
|
|
94
96
|
return self._pile[lambda m: tag in m.tags]
|
|
95
97
|
|
|
96
98
|
def count(self) -> int:
|
|
97
|
-
"""Count registered
|
|
99
|
+
"""Count registered resources."""
|
|
98
100
|
return len(self._pile)
|
|
99
101
|
|
|
100
102
|
def clear(self) -> None:
|
|
101
|
-
"""Remove all registered
|
|
103
|
+
"""Remove all registered resources."""
|
|
102
104
|
self._pile.clear()
|
|
103
105
|
self._name_index.clear()
|
|
104
106
|
|
|
105
107
|
def __len__(self) -> int:
|
|
106
|
-
"""Return number of registered
|
|
108
|
+
"""Return number of registered resources."""
|
|
107
109
|
return len(self._pile)
|
|
108
110
|
|
|
109
111
|
def __contains__(self, name: str) -> bool:
|
|
110
|
-
"""Check if
|
|
112
|
+
"""Check if resource exists (supports `name in registry`)."""
|
|
111
113
|
return name in self._name_index
|
|
112
114
|
|
|
113
115
|
def __repr__(self) -> str:
|
|
114
116
|
"""String representation."""
|
|
115
|
-
return f"
|
|
117
|
+
return f"ResourceRegistry(count={len(self)})"
|
|
@@ -14,16 +14,16 @@ from typing import TYPE_CHECKING, Any, Self
|
|
|
14
14
|
|
|
15
15
|
from typing_extensions import override
|
|
16
16
|
|
|
17
|
-
from
|
|
18
|
-
from
|
|
19
|
-
from
|
|
17
|
+
from krons.core import Event, Executor, Processor
|
|
18
|
+
from krons.resource.endpoint import APICalling
|
|
19
|
+
from krons.utils.concurrency import get_cancelled_exc_class, sleep
|
|
20
20
|
|
|
21
21
|
from .rate_limiter import TokenBucket
|
|
22
22
|
|
|
23
23
|
if TYPE_CHECKING:
|
|
24
24
|
import asyncio
|
|
25
25
|
|
|
26
|
-
from
|
|
26
|
+
from krons.core import Pile
|
|
27
27
|
|
|
28
28
|
__all__ = ("RateLimitedExecutor", "RateLimitedProcessor")
|
|
29
29
|
|
|
@@ -213,8 +213,12 @@ class RateLimitedProcessor(Processor):
|
|
|
213
213
|
"concurrency_limit": self.concurrency_limit,
|
|
214
214
|
"max_queue_size": self.max_queue_size,
|
|
215
215
|
"max_denial_tracking": self.max_denial_tracking,
|
|
216
|
-
"request_bucket": (
|
|
217
|
-
|
|
216
|
+
"request_bucket": (
|
|
217
|
+
self.request_bucket.to_dict() if self.request_bucket else None
|
|
218
|
+
),
|
|
219
|
+
"token_bucket": (
|
|
220
|
+
self.token_bucket.to_dict() if self.token_bucket else None
|
|
221
|
+
),
|
|
218
222
|
}
|
|
219
223
|
|
|
220
224
|
|
|
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
import logging
|
|
9
9
|
from dataclasses import dataclass
|
|
10
10
|
|
|
11
|
-
from
|
|
11
|
+
from krons.utils.concurrency import Lock, current_time, sleep
|
|
12
12
|
|
|
13
13
|
__all__ = ("RateLimitConfig", "TokenBucket")
|
|
14
14
|
|
|
@@ -105,7 +105,9 @@ class TokenBucket:
|
|
|
105
105
|
|
|
106
106
|
if self.tokens >= tokens:
|
|
107
107
|
self.tokens -= tokens
|
|
108
|
-
logger.debug(
|
|
108
|
+
logger.debug(
|
|
109
|
+
f"Acquired {tokens} tokens, {self.tokens:.2f} remaining"
|
|
110
|
+
)
|
|
109
111
|
return True
|
|
110
112
|
|
|
111
113
|
deficit = tokens - self.tokens
|
|
@@ -16,8 +16,8 @@ from dataclasses import dataclass, field
|
|
|
16
16
|
from enum import Enum
|
|
17
17
|
from typing import Any, TypedDict, TypeVar
|
|
18
18
|
|
|
19
|
-
from
|
|
20
|
-
from
|
|
19
|
+
from krons.errors import KronConnectionError
|
|
20
|
+
from krons.utils.concurrency import Lock, current_time, sleep
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
class _MetricsDict(TypedDict):
|
|
@@ -199,7 +199,9 @@ class CircuitBreaker:
|
|
|
199
199
|
if now - self.last_failure_time >= self.recovery_time:
|
|
200
200
|
await self._change_state(CircuitState.HALF_OPEN)
|
|
201
201
|
else:
|
|
202
|
-
recovery_remaining = self.recovery_time - (
|
|
202
|
+
recovery_remaining = self.recovery_time - (
|
|
203
|
+
now - self.last_failure_time
|
|
204
|
+
)
|
|
203
205
|
self._metrics["rejected_count"] += 1
|
|
204
206
|
|
|
205
207
|
logger.warning(
|
|
@@ -223,7 +225,9 @@ class CircuitBreaker:
|
|
|
223
225
|
|
|
224
226
|
return True, 0.0
|
|
225
227
|
|
|
226
|
-
async def execute(
|
|
228
|
+
async def execute(
|
|
229
|
+
self, func: Callable[..., Awaitable[T]], *args: Any, **kwargs: Any
|
|
230
|
+
) -> T:
|
|
227
231
|
"""Execute async function with circuit breaker protection.
|
|
228
232
|
|
|
229
233
|
Args:
|
|
@@ -259,7 +263,9 @@ class CircuitBreaker:
|
|
|
259
263
|
return result
|
|
260
264
|
|
|
261
265
|
except Exception as e:
|
|
262
|
-
is_excluded = any(
|
|
266
|
+
is_excluded = any(
|
|
267
|
+
isinstance(e, exc_type) for exc_type in self.excluded_exceptions
|
|
268
|
+
)
|
|
263
269
|
|
|
264
270
|
if not is_excluded:
|
|
265
271
|
async with self._lock:
|
|
@@ -322,7 +328,9 @@ class RetryConfig:
|
|
|
322
328
|
Returns:
|
|
323
329
|
Delay in seconds before next retry
|
|
324
330
|
"""
|
|
325
|
-
delay = min(
|
|
331
|
+
delay = min(
|
|
332
|
+
self.initial_delay * (self.exponential_base**attempt), self.max_delay
|
|
333
|
+
)
|
|
326
334
|
if self.jitter:
|
|
327
335
|
delay = delay * (0.5 + random.random() * 0.5)
|
|
328
336
|
return delay
|
|
@@ -394,7 +402,9 @@ async def retry_with_backoff(
|
|
|
394
402
|
last_exception = e
|
|
395
403
|
|
|
396
404
|
if attempt >= max_retries:
|
|
397
|
-
logger.error(
|
|
405
|
+
logger.error(
|
|
406
|
+
f"All {max_retries} retry attempts exhausted for {func.__name__}: {e}"
|
|
407
|
+
)
|
|
398
408
|
raise
|
|
399
409
|
|
|
400
410
|
delay = min(initial_delay * (exponential_base**attempt), max_delay)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
import tiktoken
|
|
9
|
+
|
|
10
|
+
from krons.errors import KronsError
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TokenCalculationError(KronsError):
|
|
16
|
+
"""Raised when token calculation fails due to encoding/model errors."""
|
|
17
|
+
|
|
18
|
+
default_message = "Token calculation failed"
|
|
19
|
+
default_retryable = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_encoding_name(value: str | None) -> str:
|
|
23
|
+
"""Get encoding name for model, with fallback chain.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Encoding name (defaults to o200k_base if model/encoding not found or None)
|
|
27
|
+
"""
|
|
28
|
+
if value is None:
|
|
29
|
+
return "o200k_base"
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
enc = tiktoken.encoding_for_model(value)
|
|
33
|
+
return enc.name
|
|
34
|
+
except KeyError:
|
|
35
|
+
# Not a known model name, try as encoding name
|
|
36
|
+
try:
|
|
37
|
+
tiktoken.get_encoding(value)
|
|
38
|
+
return value
|
|
39
|
+
except Exception as e:
|
|
40
|
+
logger.warning(
|
|
41
|
+
f"Unknown model/encoding '{value}', falling back to o200k_base: {e}"
|
|
42
|
+
)
|
|
43
|
+
return "o200k_base"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TokenCalculator:
|
|
47
|
+
@staticmethod
|
|
48
|
+
def calculate_message_tokens(messages: list[dict], /, **kwargs) -> int:
|
|
49
|
+
model = kwargs.get("model", "gpt-4o")
|
|
50
|
+
image_token_cost = kwargs.get("image_token_cost", 500)
|
|
51
|
+
tokenizer = tiktoken.get_encoding(get_encoding_name(model)).encode
|
|
52
|
+
|
|
53
|
+
num_tokens = 0
|
|
54
|
+
for msg in messages:
|
|
55
|
+
num_tokens += 4
|
|
56
|
+
_c = msg.get("content")
|
|
57
|
+
num_tokens += TokenCalculator._calculate_chatitem(
|
|
58
|
+
_c,
|
|
59
|
+
tokenizer=tokenizer,
|
|
60
|
+
model_name=model,
|
|
61
|
+
image_token_cost=image_token_cost,
|
|
62
|
+
)
|
|
63
|
+
return num_tokens # buffer for chat
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def calculate_embed_token(inputs: list[str], /, **kwargs) -> int:
|
|
67
|
+
if not inputs:
|
|
68
|
+
raise ValueError("inputs must be a non-empty list of strings")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
tokenizer = tiktoken.get_encoding(
|
|
72
|
+
get_encoding_name(kwargs.get("model", "text-embedding-3-small"))
|
|
73
|
+
).encode
|
|
74
|
+
|
|
75
|
+
return sum(
|
|
76
|
+
TokenCalculator._calculate_embed_item(i, tokenizer=tokenizer)
|
|
77
|
+
for i in inputs
|
|
78
|
+
)
|
|
79
|
+
except TokenCalculationError:
|
|
80
|
+
# Re-raise from nested calls
|
|
81
|
+
raise
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.error(f"Failed to calculate embed tokens: {e}", exc_info=True)
|
|
84
|
+
raise TokenCalculationError(f"Embed token calculation failed: {e}") from e
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def tokenize(
|
|
88
|
+
s_: str | None = None,
|
|
89
|
+
/,
|
|
90
|
+
encoding_name: str | None = None,
|
|
91
|
+
tokenizer: Callable | None = None,
|
|
92
|
+
decoder: Callable | None = None,
|
|
93
|
+
return_tokens: bool = False,
|
|
94
|
+
return_decoded: bool = False,
|
|
95
|
+
) -> int | list[int] | tuple[int, str]:
|
|
96
|
+
if not s_:
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
if not callable(tokenizer):
|
|
100
|
+
encoding_name = get_encoding_name(encoding_name)
|
|
101
|
+
tokenizer = tiktoken.get_encoding(encoding_name).encode
|
|
102
|
+
if not callable(decoder):
|
|
103
|
+
# Use encoding_name if available, otherwise fallback to default
|
|
104
|
+
decoder_encoding = encoding_name if encoding_name else "o200k_base"
|
|
105
|
+
decoder = tiktoken.get_encoding(decoder_encoding).decode
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
if return_tokens:
|
|
109
|
+
if return_decoded:
|
|
110
|
+
a = tokenizer(s_)
|
|
111
|
+
return len(a), decoder(a)
|
|
112
|
+
return tokenizer(s_)
|
|
113
|
+
return len(tokenizer(s_))
|
|
114
|
+
except Exception as e:
|
|
115
|
+
# Actual encoding failure during tokenization - this is an error
|
|
116
|
+
logger.error(
|
|
117
|
+
f"Tokenization failed for input (len={len(s_) if s_ else 0}): {e}",
|
|
118
|
+
exc_info=True,
|
|
119
|
+
)
|
|
120
|
+
raise TokenCalculationError(f"Tokenization failed: {e}") from e
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _calculate_chatitem(
|
|
124
|
+
i_, tokenizer: Callable, model_name: str, image_token_cost: int = 500
|
|
125
|
+
) -> int:
|
|
126
|
+
try:
|
|
127
|
+
if isinstance(i_, str):
|
|
128
|
+
# tokenize returns int when return_tokens=False (default)
|
|
129
|
+
return cast(int, TokenCalculator.tokenize(i_, tokenizer=tokenizer))
|
|
130
|
+
|
|
131
|
+
if isinstance(i_, dict):
|
|
132
|
+
if "text" in i_:
|
|
133
|
+
return TokenCalculator._calculate_chatitem(
|
|
134
|
+
str(i_["text"]), tokenizer, model_name, image_token_cost
|
|
135
|
+
)
|
|
136
|
+
elif "image_url" in i_:
|
|
137
|
+
return image_token_cost
|
|
138
|
+
|
|
139
|
+
if isinstance(i_, list):
|
|
140
|
+
return sum(
|
|
141
|
+
TokenCalculator._calculate_chatitem(
|
|
142
|
+
x, tokenizer, model_name, image_token_cost
|
|
143
|
+
)
|
|
144
|
+
for x in i_
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Unknown type - return 0 is valid (no text content)
|
|
148
|
+
return 0
|
|
149
|
+
except TokenCalculationError:
|
|
150
|
+
# Re-raise tokenization errors from nested calls
|
|
151
|
+
raise
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.error(
|
|
154
|
+
f"Failed to calculate chat item tokens (type={type(i_).__name__}): {e}",
|
|
155
|
+
exc_info=True,
|
|
156
|
+
)
|
|
157
|
+
raise TokenCalculationError(
|
|
158
|
+
f"Chat item token calculation failed: {e}"
|
|
159
|
+
) from e
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _calculate_embed_item(s_, tokenizer: Callable) -> int:
|
|
163
|
+
try:
|
|
164
|
+
if isinstance(s_, str):
|
|
165
|
+
# tokenize returns int when return_tokens=False (default)
|
|
166
|
+
return cast(int, TokenCalculator.tokenize(s_, tokenizer=tokenizer))
|
|
167
|
+
|
|
168
|
+
if isinstance(s_, list):
|
|
169
|
+
return sum(
|
|
170
|
+
TokenCalculator._calculate_embed_item(x, tokenizer) for x in s_
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Unknown type - return 0 is valid (no text content)
|
|
174
|
+
return 0
|
|
175
|
+
except TokenCalculationError:
|
|
176
|
+
# Re-raise tokenization errors from nested calls
|
|
177
|
+
raise
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.error(
|
|
180
|
+
f"Failed to calculate embed item tokens (type={type(s_).__name__}): {e}",
|
|
181
|
+
exc_info=True,
|
|
182
|
+
)
|
|
183
|
+
raise TokenCalculationError(
|
|
184
|
+
f"Embed item token calculation failed: {e}"
|
|
185
|
+
) from e
|