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.
Files changed (130) hide show
  1. ellements/__init__.py +57 -0
  2. ellements/agents/__init__.py +45 -0
  3. ellements/agents/backend.py +100 -0
  4. ellements/agents/builder.py +303 -0
  5. ellements/agents/claude_backend.py +200 -0
  6. ellements/agents/controller.py +237 -0
  7. ellements/agents/openai_backend.py +187 -0
  8. ellements/agents/py.typed +0 -0
  9. ellements/agents/runner.py +358 -0
  10. ellements/agents/tools.py +30 -0
  11. ellements/benchmarking/__init__.py +52 -0
  12. ellements/benchmarking/harness.py +342 -0
  13. ellements/benchmarking/py.typed +0 -0
  14. ellements/benchmarking/results.py +173 -0
  15. ellements/cli/__init__.py +80 -0
  16. ellements/cli/adapters.py +73 -0
  17. ellements/cli/agent_tui.py +1178 -0
  18. ellements/cli/components.py +411 -0
  19. ellements/cli/printer.py +229 -0
  20. ellements/cli/py.typed +0 -0
  21. ellements/core/__init__.py +112 -0
  22. ellements/core/async_utils.py +42 -0
  23. ellements/core/budgeting/__init__.py +43 -0
  24. ellements/core/budgeting/client.py +276 -0
  25. ellements/core/budgeting/protocol.py +51 -0
  26. ellements/core/budgeting/trackers.py +177 -0
  27. ellements/core/caching/__init__.py +51 -0
  28. ellements/core/caching/cache.py +73 -0
  29. ellements/core/caching/client.py +300 -0
  30. ellements/core/caching/disk.py +128 -0
  31. ellements/core/caching/keys.py +97 -0
  32. ellements/core/caching/memory.py +104 -0
  33. ellements/core/chunking.py +262 -0
  34. ellements/core/config.py +180 -0
  35. ellements/core/exceptions.py +145 -0
  36. ellements/core/llm/__init__.py +46 -0
  37. ellements/core/llm/client.py +1124 -0
  38. ellements/core/llm/images.py +226 -0
  39. ellements/core/llm/messages.py +202 -0
  40. ellements/core/llm/model_params.py +66 -0
  41. ellements/core/llm/protocol.py +146 -0
  42. ellements/core/llm/requests.py +100 -0
  43. ellements/core/llm/structured.py +91 -0
  44. ellements/core/llm/wrapper.py +79 -0
  45. ellements/core/observability/__init__.py +39 -0
  46. ellements/core/observability/events.py +169 -0
  47. ellements/core/observability/jsonl_logger.py +244 -0
  48. ellements/core/observability/markdown_formatter.py +197 -0
  49. ellements/core/observability/observer.py +56 -0
  50. ellements/core/prompting/__init__.py +14 -0
  51. ellements/core/prompting/context.py +185 -0
  52. ellements/core/prompting/guideline.py +133 -0
  53. ellements/core/prompting/persona.py +267 -0
  54. ellements/core/prompting/sources.py +92 -0
  55. ellements/core/py.typed +0 -0
  56. ellements/core/rate_limit/__init__.py +44 -0
  57. ellements/core/rate_limit/bucket.py +85 -0
  58. ellements/core/rate_limit/client.py +216 -0
  59. ellements/core/rate_limit/protocol.py +27 -0
  60. ellements/core/templating.py +126 -0
  61. ellements/core/tools/__init__.py +51 -0
  62. ellements/core/tools/dialects.py +136 -0
  63. ellements/core/tools/executor.py +48 -0
  64. ellements/core/tools/protocol.py +36 -0
  65. ellements/core/tools/records.py +28 -0
  66. ellements/core/tools/registry.py +205 -0
  67. ellements/core/tools/schemas.py +80 -0
  68. ellements/core/tools/simple.py +119 -0
  69. ellements/core/tools/spec.py +33 -0
  70. ellements/domain_specific/__init__.py +7 -0
  71. ellements/domain_specific/finance/__init__.py +188 -0
  72. ellements/domain_specific/finance/calculations.py +837 -0
  73. ellements/domain_specific/finance/charts.py +426 -0
  74. ellements/domain_specific/finance/portfolio.py +129 -0
  75. ellements/domain_specific/finance/quant_analysis.py +279 -0
  76. ellements/domain_specific/finance/risk.py +362 -0
  77. ellements/domain_specific/finance/technical_indicators.py +241 -0
  78. ellements/domain_specific/finance/tools.py +483 -0
  79. ellements/domain_specific/finance/valuation.py +312 -0
  80. ellements/domain_specific/finance/yahoo_finance.py +1523 -0
  81. ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
  82. ellements/domain_specific/py.typed +0 -0
  83. ellements/execution/__init__.py +56 -0
  84. ellements/execution/callbacks.py +149 -0
  85. ellements/execution/catalog.py +70 -0
  86. ellements/execution/collaborative.py +191 -0
  87. ellements/execution/config.py +135 -0
  88. ellements/execution/py.typed +0 -0
  89. ellements/execution/reflection.py +156 -0
  90. ellements/execution/self_consistency.py +189 -0
  91. ellements/execution/single_call.py +48 -0
  92. ellements/execution/strategies.py +237 -0
  93. ellements/execution/tree_of_thought.py +541 -0
  94. ellements/fslm/__init__.py +108 -0
  95. ellements/fslm/builtins.py +103 -0
  96. ellements/fslm/cli.py +199 -0
  97. ellements/fslm/context.py +50 -0
  98. ellements/fslm/definition.py +163 -0
  99. ellements/fslm/det.py +173 -0
  100. ellements/fslm/dsl.py +458 -0
  101. ellements/fslm/errors.py +38 -0
  102. ellements/fslm/evaluators.py +141 -0
  103. ellements/fslm/kernel.py +603 -0
  104. ellements/fslm/loading.py +123 -0
  105. ellements/fslm/models.py +623 -0
  106. ellements/fslm/nl.py +87 -0
  107. ellements/fslm/observers.py +107 -0
  108. ellements/fslm/persistence.py +72 -0
  109. ellements/fslm/py.typed +0 -0
  110. ellements/fslm/rendering.py +551 -0
  111. ellements/fslm/visualization.py +25 -0
  112. ellements/reporting/__init__.py +12 -0
  113. ellements/reporting/charts.py +364 -0
  114. ellements/reporting/html_generation.py +132 -0
  115. ellements/reporting/py.typed +0 -0
  116. ellements/reporting/visualization.py +73 -0
  117. ellements/standard_tools/__init__.py +22 -0
  118. ellements/standard_tools/py.typed +0 -0
  119. ellements/standard_tools/terminal.py +205 -0
  120. ellements/standard_tools/web/__init__.py +37 -0
  121. ellements/standard_tools/web/browser_viewer.py +214 -0
  122. ellements/standard_tools/web/crawler.py +454 -0
  123. ellements/standard_tools/web/search.py +399 -0
  124. ellements/standard_tools/web/youtube.py +1297 -0
  125. ellements-0.2.0.dist-info/METADATA +368 -0
  126. ellements-0.2.0.dist-info/RECORD +130 -0
  127. ellements-0.2.0.dist-info/WHEEL +5 -0
  128. ellements-0.2.0.dist-info/entry_points.txt +2 -0
  129. ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
  130. 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
+ ]