ellements 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.
- ellements/__init__.py +57 -0
- ellements/agents/__init__.py +45 -0
- ellements/agents/backend.py +100 -0
- ellements/agents/builder.py +303 -0
- ellements/agents/claude_backend.py +200 -0
- ellements/agents/controller.py +237 -0
- ellements/agents/openai_backend.py +187 -0
- ellements/agents/py.typed +0 -0
- ellements/agents/runner.py +358 -0
- ellements/agents/tools.py +30 -0
- ellements/benchmarking/__init__.py +52 -0
- ellements/benchmarking/harness.py +342 -0
- ellements/benchmarking/py.typed +0 -0
- ellements/benchmarking/results.py +173 -0
- ellements/cli/__init__.py +80 -0
- ellements/cli/adapters.py +73 -0
- ellements/cli/agent_tui.py +1178 -0
- ellements/cli/components.py +411 -0
- ellements/cli/printer.py +229 -0
- ellements/cli/py.typed +0 -0
- ellements/core/__init__.py +112 -0
- ellements/core/async_utils.py +42 -0
- ellements/core/budgeting/__init__.py +43 -0
- ellements/core/budgeting/client.py +276 -0
- ellements/core/budgeting/protocol.py +51 -0
- ellements/core/budgeting/trackers.py +177 -0
- ellements/core/caching/__init__.py +51 -0
- ellements/core/caching/cache.py +73 -0
- ellements/core/caching/client.py +300 -0
- ellements/core/caching/disk.py +128 -0
- ellements/core/caching/keys.py +97 -0
- ellements/core/caching/memory.py +104 -0
- ellements/core/chunking.py +262 -0
- ellements/core/config.py +180 -0
- ellements/core/exceptions.py +145 -0
- ellements/core/llm/__init__.py +46 -0
- ellements/core/llm/client.py +1124 -0
- ellements/core/llm/images.py +226 -0
- ellements/core/llm/messages.py +202 -0
- ellements/core/llm/model_params.py +66 -0
- ellements/core/llm/protocol.py +146 -0
- ellements/core/llm/requests.py +100 -0
- ellements/core/llm/structured.py +91 -0
- ellements/core/llm/wrapper.py +79 -0
- ellements/core/observability/__init__.py +39 -0
- ellements/core/observability/events.py +169 -0
- ellements/core/observability/jsonl_logger.py +244 -0
- ellements/core/observability/markdown_formatter.py +197 -0
- ellements/core/observability/observer.py +56 -0
- ellements/core/prompting/__init__.py +14 -0
- ellements/core/prompting/context.py +185 -0
- ellements/core/prompting/guideline.py +133 -0
- ellements/core/prompting/persona.py +267 -0
- ellements/core/prompting/sources.py +92 -0
- ellements/core/py.typed +0 -0
- ellements/core/rate_limit/__init__.py +44 -0
- ellements/core/rate_limit/bucket.py +85 -0
- ellements/core/rate_limit/client.py +216 -0
- ellements/core/rate_limit/protocol.py +27 -0
- ellements/core/templating.py +126 -0
- ellements/core/tools/__init__.py +51 -0
- ellements/core/tools/dialects.py +136 -0
- ellements/core/tools/executor.py +48 -0
- ellements/core/tools/protocol.py +36 -0
- ellements/core/tools/records.py +28 -0
- ellements/core/tools/registry.py +205 -0
- ellements/core/tools/schemas.py +80 -0
- ellements/core/tools/simple.py +119 -0
- ellements/core/tools/spec.py +33 -0
- ellements/domain_specific/__init__.py +7 -0
- ellements/domain_specific/finance/__init__.py +188 -0
- ellements/domain_specific/finance/calculations.py +837 -0
- ellements/domain_specific/finance/charts.py +426 -0
- ellements/domain_specific/finance/portfolio.py +129 -0
- ellements/domain_specific/finance/quant_analysis.py +279 -0
- ellements/domain_specific/finance/risk.py +362 -0
- ellements/domain_specific/finance/technical_indicators.py +241 -0
- ellements/domain_specific/finance/tools.py +483 -0
- ellements/domain_specific/finance/valuation.py +312 -0
- ellements/domain_specific/finance/yahoo_finance.py +1523 -0
- ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
- ellements/domain_specific/py.typed +0 -0
- ellements/execution/__init__.py +56 -0
- ellements/execution/callbacks.py +149 -0
- ellements/execution/catalog.py +70 -0
- ellements/execution/collaborative.py +191 -0
- ellements/execution/config.py +135 -0
- ellements/execution/py.typed +0 -0
- ellements/execution/reflection.py +156 -0
- ellements/execution/self_consistency.py +189 -0
- ellements/execution/single_call.py +48 -0
- ellements/execution/strategies.py +237 -0
- ellements/execution/tree_of_thought.py +541 -0
- ellements/fslm/__init__.py +108 -0
- ellements/fslm/builtins.py +103 -0
- ellements/fslm/cli.py +199 -0
- ellements/fslm/context.py +50 -0
- ellements/fslm/definition.py +163 -0
- ellements/fslm/det.py +173 -0
- ellements/fslm/dsl.py +458 -0
- ellements/fslm/errors.py +38 -0
- ellements/fslm/evaluators.py +141 -0
- ellements/fslm/kernel.py +603 -0
- ellements/fslm/loading.py +123 -0
- ellements/fslm/models.py +623 -0
- ellements/fslm/nl.py +87 -0
- ellements/fslm/observers.py +107 -0
- ellements/fslm/persistence.py +72 -0
- ellements/fslm/py.typed +0 -0
- ellements/fslm/rendering.py +551 -0
- ellements/fslm/visualization.py +25 -0
- ellements/reporting/__init__.py +12 -0
- ellements/reporting/charts.py +364 -0
- ellements/reporting/html_generation.py +132 -0
- ellements/reporting/py.typed +0 -0
- ellements/reporting/visualization.py +73 -0
- ellements/standard_tools/__init__.py +22 -0
- ellements/standard_tools/py.typed +0 -0
- ellements/standard_tools/terminal.py +205 -0
- ellements/standard_tools/web/__init__.py +37 -0
- ellements/standard_tools/web/browser_viewer.py +214 -0
- ellements/standard_tools/web/crawler.py +454 -0
- ellements/standard_tools/web/search.py +399 -0
- ellements/standard_tools/web/youtube.py +1297 -0
- ellements-0.2.0.dist-info/METADATA +368 -0
- ellements-0.2.0.dist-info/RECORD +130 -0
- ellements-0.2.0.dist-info/WHEEL +5 -0
- ellements-0.2.0.dist-info/entry_points.txt +2 -0
- ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
- ellements-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Optional client-side rate limiting for LLM calls.
|
|
2
|
+
|
|
3
|
+
Two pieces:
|
|
4
|
+
|
|
5
|
+
1. :class:`RateLimiterProtocol` — a tiny async Protocol with a single
|
|
6
|
+
:meth:`acquire` method. Anything implementing it is a valid
|
|
7
|
+
limiter.
|
|
8
|
+
2. :class:`TokenBucketRateLimiter` — a classic token-bucket
|
|
9
|
+
implementation: refills at a configurable rate, accepts bursts up
|
|
10
|
+
to a configurable capacity. Bounded contention via a single
|
|
11
|
+
:class:`asyncio.Lock`.
|
|
12
|
+
3. :class:`RateLimitedLLMClient` — a wrapper around any
|
|
13
|
+
:class:`ellements.core.LLMClientProtocol` that calls
|
|
14
|
+
``await limiter.acquire(...)`` before each request.
|
|
15
|
+
|
|
16
|
+
The wrapper itself satisfies :class:`LLMClientProtocol` so it slots
|
|
17
|
+
into any consumer structurally.
|
|
18
|
+
|
|
19
|
+
Example::
|
|
20
|
+
|
|
21
|
+
from ellements.core import LLMClient
|
|
22
|
+
from ellements.core.rate_limit import (
|
|
23
|
+
RateLimitedLLMClient,
|
|
24
|
+
TokenBucketRateLimiter,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
limiter = TokenBucketRateLimiter(rate_per_second=2.0, capacity=4)
|
|
28
|
+
client = RateLimitedLLMClient(
|
|
29
|
+
inner=LLMClient(model="openai/gpt-4o-mini"),
|
|
30
|
+
limiter=limiter,
|
|
31
|
+
)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from .bucket import TokenBucketRateLimiter
|
|
37
|
+
from .client import RateLimitedLLMClient
|
|
38
|
+
from .protocol import RateLimiterProtocol
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"RateLimitedLLMClient",
|
|
42
|
+
"RateLimiterProtocol",
|
|
43
|
+
"TokenBucketRateLimiter",
|
|
44
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Token-bucket :class:`RateLimiterProtocol` implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TokenBucketRateLimiter:
|
|
10
|
+
"""Classic token-bucket limiter satisfying :class:`RateLimiterProtocol`.
|
|
11
|
+
|
|
12
|
+
The bucket is refilled continuously at ``rate_per_second`` units
|
|
13
|
+
per second, up to a maximum of ``capacity`` units. Each
|
|
14
|
+
:meth:`acquire` call removes ``cost`` units; if the bucket lacks
|
|
15
|
+
enough, the caller sleeps until refill catches up.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
rate_per_second: Refill rate. Must be positive. A value of
|
|
19
|
+
``2`` means up to two single-unit calls per second
|
|
20
|
+
(steady state).
|
|
21
|
+
capacity: Maximum bucket size. Bursts up to this size are
|
|
22
|
+
allowed when the bucket starts full / has been idle.
|
|
23
|
+
Defaults to ``rate_per_second`` (no extra burst budget).
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
ValueError: If *rate_per_second* or *capacity* is not
|
|
27
|
+
positive, or if a single :meth:`acquire` call requests a
|
|
28
|
+
*cost* larger than the bucket's *capacity* (such a call
|
|
29
|
+
could never be satisfied).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
rate_per_second: float,
|
|
35
|
+
*,
|
|
36
|
+
capacity: float | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
if rate_per_second <= 0:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"rate_per_second must be positive, got {rate_per_second}."
|
|
41
|
+
)
|
|
42
|
+
if capacity is None:
|
|
43
|
+
capacity = rate_per_second
|
|
44
|
+
if capacity <= 0:
|
|
45
|
+
raise ValueError(f"capacity must be positive, got {capacity}.")
|
|
46
|
+
self._rate = rate_per_second
|
|
47
|
+
self._capacity = float(capacity)
|
|
48
|
+
self._tokens = float(capacity)
|
|
49
|
+
self._last_refill = time.monotonic()
|
|
50
|
+
self._lock = asyncio.Lock()
|
|
51
|
+
|
|
52
|
+
async def acquire(self, cost: float = 1.0) -> None:
|
|
53
|
+
"""Block until *cost* tokens are available, then deduct them.
|
|
54
|
+
|
|
55
|
+
Returns immediately if *cost* is zero or negative. Raises
|
|
56
|
+
:class:`ValueError` if *cost* exceeds the bucket's capacity
|
|
57
|
+
(such a request could never be satisfied).
|
|
58
|
+
"""
|
|
59
|
+
if cost <= 0:
|
|
60
|
+
return
|
|
61
|
+
if cost > self._capacity:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"acquire(cost={cost}) exceeds capacity={self._capacity}; "
|
|
64
|
+
f"this call could never be satisfied."
|
|
65
|
+
)
|
|
66
|
+
while True:
|
|
67
|
+
async with self._lock:
|
|
68
|
+
self._refill_locked()
|
|
69
|
+
if self._tokens >= cost:
|
|
70
|
+
self._tokens -= cost
|
|
71
|
+
return
|
|
72
|
+
deficit = cost - self._tokens
|
|
73
|
+
wait_seconds = deficit / self._rate
|
|
74
|
+
await asyncio.sleep(wait_seconds)
|
|
75
|
+
|
|
76
|
+
def _refill_locked(self) -> None:
|
|
77
|
+
now = time.monotonic()
|
|
78
|
+
elapsed = now - self._last_refill
|
|
79
|
+
if elapsed <= 0:
|
|
80
|
+
return
|
|
81
|
+
self._tokens = min(self._capacity, self._tokens + elapsed * self._rate)
|
|
82
|
+
self._last_refill = now
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
__all__ = ["TokenBucketRateLimiter"]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Rate-limited :class:`LLMClientProtocol` wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Iterable, Mapping
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
from ..llm.images import ImageGenerationResponse
|
|
9
|
+
from ..llm.messages import Conversation, MessageInput
|
|
10
|
+
from ..llm.protocol import LLMClientProtocol
|
|
11
|
+
from ..llm.wrapper import LLMClientWrapper
|
|
12
|
+
from ..tools import ToolDialect, ToolExecutor, ToolRegistry
|
|
13
|
+
from ..tools.records import ToolCallResponse
|
|
14
|
+
from .protocol import RateLimiterProtocol
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RateLimitedLLMClient(LLMClientWrapper):
|
|
20
|
+
"""LLM client wrapper that acquires a permit before each call.
|
|
21
|
+
|
|
22
|
+
Every public method calls ``await limiter.acquire(cost)`` before
|
|
23
|
+
delegating to the inner client. The default ``cost`` is ``1.0``
|
|
24
|
+
per call, but callers can supply a custom per-method cost map
|
|
25
|
+
(e.g. ``{"generate_image": 5.0}``) to reflect the relative
|
|
26
|
+
expense of each operation.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
inner: Any object satisfying :class:`LLMClientProtocol`.
|
|
30
|
+
limiter: Any object satisfying :class:`RateLimiterProtocol`. Often a
|
|
31
|
+
:class:`TokenBucketRateLimiter`, but could be backed by
|
|
32
|
+
Redis or an external service.
|
|
33
|
+
cost_per_method: Optional ``{method_name: cost}`` overrides.
|
|
34
|
+
Missing methods fall back to ``default_cost``.
|
|
35
|
+
default_cost: Cost used for any method not present in
|
|
36
|
+
*cost_per_method*. Defaults to ``1.0``.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
inner: LLMClientProtocol,
|
|
43
|
+
limiter: RateLimiterProtocol,
|
|
44
|
+
cost_per_method: Mapping[str, float] | None = None,
|
|
45
|
+
default_cost: float = 1.0,
|
|
46
|
+
) -> None:
|
|
47
|
+
super().__init__(inner=inner)
|
|
48
|
+
self.limiter = limiter
|
|
49
|
+
self._cost_per_method = dict(cost_per_method or {})
|
|
50
|
+
self._default_cost = default_cost
|
|
51
|
+
|
|
52
|
+
def _cost(self, method_name: str) -> float:
|
|
53
|
+
return self._cost_per_method.get(method_name, self._default_cost)
|
|
54
|
+
|
|
55
|
+
async def complete(
|
|
56
|
+
self,
|
|
57
|
+
messages: MessageInput,
|
|
58
|
+
*,
|
|
59
|
+
model: str | None = None,
|
|
60
|
+
temperature: float = 0.7,
|
|
61
|
+
max_tokens: int | None = None,
|
|
62
|
+
**kwargs: Any,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Acquire a permit for ``complete`` then forward to the inner client."""
|
|
65
|
+
await self.limiter.acquire(self._cost("complete"))
|
|
66
|
+
return await self._inner.complete(
|
|
67
|
+
messages,
|
|
68
|
+
model=model,
|
|
69
|
+
temperature=temperature,
|
|
70
|
+
max_tokens=max_tokens,
|
|
71
|
+
**kwargs,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
async def complete_structured(
|
|
75
|
+
self,
|
|
76
|
+
messages: MessageInput,
|
|
77
|
+
response_model: type[T],
|
|
78
|
+
*,
|
|
79
|
+
model: str | None = None,
|
|
80
|
+
temperature: float = 0.7,
|
|
81
|
+
max_tokens: int | None = None,
|
|
82
|
+
**kwargs: Any,
|
|
83
|
+
) -> T:
|
|
84
|
+
"""Acquire a permit for ``complete_structured`` then forward."""
|
|
85
|
+
await self.limiter.acquire(self._cost("complete_structured"))
|
|
86
|
+
return await self._inner.complete_structured(
|
|
87
|
+
messages,
|
|
88
|
+
response_model,
|
|
89
|
+
model=model,
|
|
90
|
+
temperature=temperature,
|
|
91
|
+
max_tokens=max_tokens,
|
|
92
|
+
**kwargs,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def complete_with_tools(
|
|
96
|
+
self,
|
|
97
|
+
messages: MessageInput,
|
|
98
|
+
tools: ToolRegistry | Mapping[str, Any] | Iterable[Any],
|
|
99
|
+
*,
|
|
100
|
+
tool_executor: ToolExecutor | None = None,
|
|
101
|
+
dialect: ToolDialect | None = None,
|
|
102
|
+
model: str | None = None,
|
|
103
|
+
temperature: float = 0.7,
|
|
104
|
+
max_tokens: int | None = None,
|
|
105
|
+
max_iterations: int = 10,
|
|
106
|
+
**kwargs: Any,
|
|
107
|
+
) -> ToolCallResponse:
|
|
108
|
+
"""Acquire a single permit for the whole tool-using turn.
|
|
109
|
+
|
|
110
|
+
Note: tool follow-up calls made by the inner client are **not**
|
|
111
|
+
re-throttled here. If you want per-iteration throttling, wrap
|
|
112
|
+
the inner client's tool-execution path instead.
|
|
113
|
+
"""
|
|
114
|
+
await self.limiter.acquire(self._cost("complete_with_tools"))
|
|
115
|
+
return await self._inner.complete_with_tools(
|
|
116
|
+
messages,
|
|
117
|
+
tools,
|
|
118
|
+
tool_executor=tool_executor,
|
|
119
|
+
dialect=dialect,
|
|
120
|
+
model=model,
|
|
121
|
+
temperature=temperature,
|
|
122
|
+
max_tokens=max_tokens,
|
|
123
|
+
max_iterations=max_iterations,
|
|
124
|
+
**kwargs,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
async def stream(
|
|
128
|
+
self,
|
|
129
|
+
messages: MessageInput,
|
|
130
|
+
*,
|
|
131
|
+
model: str | None = None,
|
|
132
|
+
temperature: float = 0.7,
|
|
133
|
+
max_tokens: int | None = None,
|
|
134
|
+
**kwargs: Any,
|
|
135
|
+
) -> AsyncIterator[str]:
|
|
136
|
+
"""Acquire a permit for ``stream`` then yield from the inner stream."""
|
|
137
|
+
await self.limiter.acquire(self._cost("stream"))
|
|
138
|
+
async for chunk in self._inner.stream(
|
|
139
|
+
messages,
|
|
140
|
+
model=model,
|
|
141
|
+
temperature=temperature,
|
|
142
|
+
max_tokens=max_tokens,
|
|
143
|
+
**kwargs,
|
|
144
|
+
):
|
|
145
|
+
yield chunk
|
|
146
|
+
|
|
147
|
+
async def continue_conversation(
|
|
148
|
+
self,
|
|
149
|
+
conversation: Conversation,
|
|
150
|
+
user_message: str,
|
|
151
|
+
*,
|
|
152
|
+
temperature: float = 0.7,
|
|
153
|
+
max_tokens: int | None = None,
|
|
154
|
+
**kwargs: Any,
|
|
155
|
+
) -> str:
|
|
156
|
+
"""Acquire a permit for ``continue_conversation`` then forward."""
|
|
157
|
+
await self.limiter.acquire(self._cost("continue_conversation"))
|
|
158
|
+
return await self._inner.continue_conversation(
|
|
159
|
+
conversation,
|
|
160
|
+
user_message,
|
|
161
|
+
temperature=temperature,
|
|
162
|
+
max_tokens=max_tokens,
|
|
163
|
+
**kwargs,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
async def loglikelihood(
|
|
167
|
+
self,
|
|
168
|
+
context: str,
|
|
169
|
+
continuation: str,
|
|
170
|
+
*,
|
|
171
|
+
model: str | None = None,
|
|
172
|
+
max_tokens: int | None = None,
|
|
173
|
+
**kwargs: Any,
|
|
174
|
+
) -> tuple[float, bool]:
|
|
175
|
+
"""Acquire a permit for ``loglikelihood`` then forward."""
|
|
176
|
+
await self.limiter.acquire(self._cost("loglikelihood"))
|
|
177
|
+
return await self._inner.loglikelihood(
|
|
178
|
+
context,
|
|
179
|
+
continuation,
|
|
180
|
+
model=model,
|
|
181
|
+
max_tokens=max_tokens,
|
|
182
|
+
**kwargs,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
async def generate_image(
|
|
186
|
+
self,
|
|
187
|
+
prompt: str,
|
|
188
|
+
*,
|
|
189
|
+
model: str | None = None,
|
|
190
|
+
n: int = 1,
|
|
191
|
+
size: str | None = None,
|
|
192
|
+
quality: str | None = None,
|
|
193
|
+
style: str | None = None,
|
|
194
|
+
response_format: str = "url",
|
|
195
|
+
**kwargs: Any,
|
|
196
|
+
) -> ImageGenerationResponse:
|
|
197
|
+
"""Acquire a permit for ``generate_image`` then forward.
|
|
198
|
+
|
|
199
|
+
Image generation typically costs more than text completions;
|
|
200
|
+
pass a higher cost via ``cost_per_method={"generate_image": …}``
|
|
201
|
+
to reflect that.
|
|
202
|
+
"""
|
|
203
|
+
await self.limiter.acquire(self._cost("generate_image"))
|
|
204
|
+
return await self._inner.generate_image(
|
|
205
|
+
prompt,
|
|
206
|
+
model=model,
|
|
207
|
+
n=n,
|
|
208
|
+
size=size,
|
|
209
|
+
quality=quality,
|
|
210
|
+
style=style,
|
|
211
|
+
response_format=response_format,
|
|
212
|
+
**kwargs,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
__all__ = ["RateLimitedLLMClient"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Structural protocol for rate limiters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class RateLimiterProtocol(Protocol):
|
|
10
|
+
"""Async-safe permit acquisition.
|
|
11
|
+
|
|
12
|
+
A limiter exposes a single coroutine: :meth:`acquire`. Callers
|
|
13
|
+
``await`` it before performing the rate-limited action and the
|
|
14
|
+
limiter blocks until enough capacity is available.
|
|
15
|
+
|
|
16
|
+
Implementations are free to model "capacity" however they want
|
|
17
|
+
(requests-per-second, tokens-per-minute, bytes-per-hour, ...).
|
|
18
|
+
The ``cost`` argument lets callers indicate that a single
|
|
19
|
+
operation consumes multiple units of capacity (e.g. a long
|
|
20
|
+
prompt counts as 50 tokens).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
async def acquire(self, cost: float = 1.0) -> None:
|
|
24
|
+
"""Block until *cost* units of capacity become available."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["RateLimiterProtocol"]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Template loading and rendering helpers.
|
|
2
|
+
|
|
3
|
+
Templates can be loaded either from a filesystem directory or from
|
|
4
|
+
package resources via ``importlib.resources``. Mustache-style
|
|
5
|
+
substitution is delegated to :mod:`chevron`.
|
|
6
|
+
|
|
7
|
+
The renderer validates its source at construction so misconfiguration
|
|
8
|
+
fails fast (rather than at the first call).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from importlib.resources import files
|
|
16
|
+
from importlib.resources.abc import Traversable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import chevron
|
|
21
|
+
|
|
22
|
+
_logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TemplateRenderer:
|
|
26
|
+
"""Load and render mustache templates from a filesystem dir or a package.
|
|
27
|
+
|
|
28
|
+
Exactly one of ``template_dir`` or ``package`` must be provided.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
template_dir: Directory containing template files.
|
|
32
|
+
package: Importable package name whose resources hold templates.
|
|
33
|
+
resource_root: Sub-path within *package* (or *template_dir*) to
|
|
34
|
+
resolve template paths against. Default ``"prompts"``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
template_dir: Path | str | None = None,
|
|
40
|
+
*,
|
|
41
|
+
package: str | None = None,
|
|
42
|
+
resource_root: str = "prompts",
|
|
43
|
+
) -> None:
|
|
44
|
+
if (template_dir is None) == (package is None):
|
|
45
|
+
raise ValueError(
|
|
46
|
+
"TemplateRenderer requires exactly one of template_dir or package."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
self.resource_root = resource_root.strip("/")
|
|
50
|
+
|
|
51
|
+
if template_dir is not None:
|
|
52
|
+
self.template_dir: Path | None = Path(template_dir)
|
|
53
|
+
if not self.template_dir.exists():
|
|
54
|
+
raise FileNotFoundError(
|
|
55
|
+
f"Template directory not found: {self.template_dir}"
|
|
56
|
+
)
|
|
57
|
+
self.package: str | None = None
|
|
58
|
+
else:
|
|
59
|
+
assert package is not None
|
|
60
|
+
self.template_dir = None
|
|
61
|
+
self.package = package
|
|
62
|
+
# Probe the package resource root to fail-fast on bad config.
|
|
63
|
+
try:
|
|
64
|
+
files(package)
|
|
65
|
+
except ModuleNotFoundError as exc:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"TemplateRenderer package {package!r} cannot be imported: {exc}"
|
|
68
|
+
) from exc
|
|
69
|
+
|
|
70
|
+
def _resource_traversable(self, template_path: str) -> Traversable:
|
|
71
|
+
assert self.package is not None
|
|
72
|
+
resource = files(self.package)
|
|
73
|
+
if self.resource_root:
|
|
74
|
+
resource = resource.joinpath(*self.resource_root.split("/"))
|
|
75
|
+
return resource.joinpath(*template_path.split("/"))
|
|
76
|
+
|
|
77
|
+
def load_template(self, template_path: str) -> str:
|
|
78
|
+
"""Read the raw template text at *template_path*."""
|
|
79
|
+
if self.template_dir is not None:
|
|
80
|
+
return (self.template_dir / template_path).read_text(encoding="utf-8")
|
|
81
|
+
return self._resource_traversable(template_path).read_text(encoding="utf-8")
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def render_template(template_content: str, context: Mapping[str, Any]) -> str:
|
|
85
|
+
"""Render *template_content* against *context* using mustache syntax."""
|
|
86
|
+
rendered: str = chevron.render(template_content, dict(context))
|
|
87
|
+
return rendered
|
|
88
|
+
|
|
89
|
+
def load_and_render(self, template_path: str, context: Mapping[str, Any]) -> str:
|
|
90
|
+
"""Convenience: load *template_path* and render it against *context*."""
|
|
91
|
+
return self.render_template(self.load_template(template_path), context)
|
|
92
|
+
|
|
93
|
+
def render_with_fallback(
|
|
94
|
+
self,
|
|
95
|
+
template_path: str,
|
|
96
|
+
context: Mapping[str, Any],
|
|
97
|
+
fallback: str,
|
|
98
|
+
*,
|
|
99
|
+
silent: bool = False,
|
|
100
|
+
) -> str:
|
|
101
|
+
"""Render a template, returning *fallback* on any error."""
|
|
102
|
+
try:
|
|
103
|
+
return self.load_and_render(template_path, context)
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
if not silent:
|
|
106
|
+
_logger.warning(
|
|
107
|
+
"Error loading template %r: %s. Using fallback content.",
|
|
108
|
+
template_path,
|
|
109
|
+
exc,
|
|
110
|
+
)
|
|
111
|
+
return fallback
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def render_prompt_template(
|
|
115
|
+
template_path: str,
|
|
116
|
+
*,
|
|
117
|
+
package: str,
|
|
118
|
+
resource_root: str = "prompts",
|
|
119
|
+
**kwargs: Any,
|
|
120
|
+
) -> str:
|
|
121
|
+
"""One-shot helper to load+render a packaged template with kwargs as context."""
|
|
122
|
+
renderer = TemplateRenderer(package=package, resource_root=resource_root)
|
|
123
|
+
return renderer.load_and_render(template_path, kwargs)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = ["TemplateRenderer", "render_prompt_template"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Provider-neutral tool architecture.
|
|
2
|
+
|
|
3
|
+
This subpackage defines the canonical tool surface every LLM call and
|
|
4
|
+
every agent backend speaks to:
|
|
5
|
+
|
|
6
|
+
- :class:`Tool` — the duck-typed Protocol.
|
|
7
|
+
- :class:`ToolSpec` — the canonical in-memory record.
|
|
8
|
+
- :class:`SimpleTool` — convenience builder from a Python callable.
|
|
9
|
+
- :class:`ToolRegistry` — composable typed collection.
|
|
10
|
+
- :class:`ToolDialect` and built-in dialects — provider-specific wire
|
|
11
|
+
formats (:class:`OpenAIChatDialect`, :class:`OpenAIResponsesDialect`,
|
|
12
|
+
:class:`AnthropicDialect`, :class:`GeminiDialect`).
|
|
13
|
+
- :class:`ToolCallRecord` / :class:`ToolCallResponse` — result records.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from .dialects import (
|
|
19
|
+
AnthropicDialect,
|
|
20
|
+
GeminiDialect,
|
|
21
|
+
OpenAIChatDialect,
|
|
22
|
+
OpenAIResponsesDialect,
|
|
23
|
+
ToolDialect,
|
|
24
|
+
default_dialect_for_model,
|
|
25
|
+
)
|
|
26
|
+
from .executor import ToolExecutor, stringify_tool_result
|
|
27
|
+
from .protocol import Tool
|
|
28
|
+
from .records import ToolCallRecord, ToolCallResponse
|
|
29
|
+
from .registry import ToolExecutorFn, ToolInput, ToolRegistry
|
|
30
|
+
from .simple import SimpleTool, bind_json_invoker
|
|
31
|
+
from .spec import ToolSpec
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"AnthropicDialect",
|
|
35
|
+
"GeminiDialect",
|
|
36
|
+
"OpenAIChatDialect",
|
|
37
|
+
"OpenAIResponsesDialect",
|
|
38
|
+
"SimpleTool",
|
|
39
|
+
"Tool",
|
|
40
|
+
"ToolCallRecord",
|
|
41
|
+
"ToolCallResponse",
|
|
42
|
+
"ToolDialect",
|
|
43
|
+
"ToolExecutor",
|
|
44
|
+
"ToolExecutorFn",
|
|
45
|
+
"ToolInput",
|
|
46
|
+
"ToolRegistry",
|
|
47
|
+
"ToolSpec",
|
|
48
|
+
"bind_json_invoker",
|
|
49
|
+
"default_dialect_for_model",
|
|
50
|
+
"stringify_tool_result",
|
|
51
|
+
]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Wire-format dialects for LLM-provider tool definitions.
|
|
2
|
+
|
|
3
|
+
A :class:`ToolDialect` translates a provider-neutral
|
|
4
|
+
:class:`~ellements.core.tools.ToolSpec` into the JSON shape a given
|
|
5
|
+
provider's API expects. Adding support for a new provider is one new
|
|
6
|
+
dialect class with no churn elsewhere.
|
|
7
|
+
|
|
8
|
+
The built-in dialects are:
|
|
9
|
+
|
|
10
|
+
- :class:`OpenAIChatDialect` — OpenAI Chat Completions / LiteLLM default.
|
|
11
|
+
- :class:`OpenAIResponsesDialect` — OpenAI Responses API (flatter shape).
|
|
12
|
+
- :class:`AnthropicDialect` — Anthropic Messages API direct.
|
|
13
|
+
- :class:`GeminiDialect` — Google Gemini direct API.
|
|
14
|
+
|
|
15
|
+
:func:`default_dialect_for_model` picks a sensible dialect from a model
|
|
16
|
+
name; callers can override explicitly when needed.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Any, ClassVar, Protocol, runtime_checkable
|
|
23
|
+
|
|
24
|
+
from .spec import ToolSpec
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class ToolDialect(Protocol):
|
|
29
|
+
"""Emits a provider-specific tool definition from a :class:`ToolSpec`."""
|
|
30
|
+
|
|
31
|
+
name: ClassVar[str]
|
|
32
|
+
"""Stable dialect identifier (e.g. ``"openai_chat"``)."""
|
|
33
|
+
|
|
34
|
+
def emit(self, spec: ToolSpec) -> dict[str, Any]:
|
|
35
|
+
"""Return the provider-specific dictionary for *spec*."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True, frozen=True)
|
|
39
|
+
class OpenAIChatDialect:
|
|
40
|
+
"""OpenAI Chat Completions wire format (also used by LiteLLM)."""
|
|
41
|
+
|
|
42
|
+
name: ClassVar[str] = "openai_chat"
|
|
43
|
+
|
|
44
|
+
def emit(self, spec: ToolSpec) -> dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"type": "function",
|
|
47
|
+
"function": {
|
|
48
|
+
"name": spec.name,
|
|
49
|
+
"description": spec.description,
|
|
50
|
+
"parameters": spec.params_json_schema,
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True, frozen=True)
|
|
56
|
+
class OpenAIResponsesDialect:
|
|
57
|
+
"""OpenAI Responses API wire format (flat, no ``function`` envelope)."""
|
|
58
|
+
|
|
59
|
+
name: ClassVar[str] = "openai_responses"
|
|
60
|
+
|
|
61
|
+
def emit(self, spec: ToolSpec) -> dict[str, Any]:
|
|
62
|
+
return {
|
|
63
|
+
"type": "function",
|
|
64
|
+
"name": spec.name,
|
|
65
|
+
"description": spec.description,
|
|
66
|
+
"parameters": spec.params_json_schema,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(slots=True, frozen=True)
|
|
71
|
+
class AnthropicDialect:
|
|
72
|
+
"""Anthropic Messages API wire format (``input_schema`` key)."""
|
|
73
|
+
|
|
74
|
+
name: ClassVar[str] = "anthropic"
|
|
75
|
+
|
|
76
|
+
def emit(self, spec: ToolSpec) -> dict[str, Any]:
|
|
77
|
+
return {
|
|
78
|
+
"name": spec.name,
|
|
79
|
+
"description": spec.description,
|
|
80
|
+
"input_schema": spec.params_json_schema,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(slots=True, frozen=True)
|
|
85
|
+
class GeminiDialect:
|
|
86
|
+
"""Google Gemini direct API wire format."""
|
|
87
|
+
|
|
88
|
+
name: ClassVar[str] = "gemini"
|
|
89
|
+
|
|
90
|
+
def emit(self, spec: ToolSpec) -> dict[str, Any]:
|
|
91
|
+
return {
|
|
92
|
+
"name": spec.name,
|
|
93
|
+
"description": spec.description,
|
|
94
|
+
"parameters": spec.params_json_schema,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def default_dialect_for_model(
|
|
99
|
+
model: str,
|
|
100
|
+
*,
|
|
101
|
+
use_responses_api: bool = False,
|
|
102
|
+
) -> ToolDialect:
|
|
103
|
+
"""Pick the appropriate :class:`ToolDialect` for *model*.
|
|
104
|
+
|
|
105
|
+
LiteLLM normalizes every provider onto the OpenAI Chat Completions
|
|
106
|
+
tool wire format, so the default for litellm-routed calls is
|
|
107
|
+
:class:`OpenAIChatDialect`. For direct (non-litellm) clients,
|
|
108
|
+
Anthropic/Gemini-specific dialects apply.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
model: The model identifier, possibly prefixed with a provider
|
|
112
|
+
slug (e.g. ``"anthropic/claude-3-5-sonnet"``).
|
|
113
|
+
use_responses_api: If true, prefer
|
|
114
|
+
:class:`OpenAIResponsesDialect` for OpenAI models.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
A :class:`ToolDialect` instance.
|
|
118
|
+
"""
|
|
119
|
+
model_lc = model.lower()
|
|
120
|
+
if model_lc.startswith("anthropic/") or model_lc.startswith("claude-"):
|
|
121
|
+
return AnthropicDialect()
|
|
122
|
+
if model_lc.startswith("gemini/") or model_lc.startswith("gemini-"):
|
|
123
|
+
return GeminiDialect()
|
|
124
|
+
if use_responses_api:
|
|
125
|
+
return OpenAIResponsesDialect()
|
|
126
|
+
return OpenAIChatDialect()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
__all__ = [
|
|
130
|
+
"AnthropicDialect",
|
|
131
|
+
"GeminiDialect",
|
|
132
|
+
"OpenAIChatDialect",
|
|
133
|
+
"OpenAIResponsesDialect",
|
|
134
|
+
"ToolDialect",
|
|
135
|
+
"default_dialect_for_model",
|
|
136
|
+
]
|