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,112 @@
1
+ """Core primitives and reusable foundations for ellements.
2
+
3
+ The top-level surface is intentionally small — the **happy path** of
4
+ client + messages + tools + prompts + observability + the full error
5
+ taxonomy. Specialized layers live in subpackages and are imported
6
+ directly from them. The subpackage path is the canonical one; the
7
+ top-level re-exports below exist purely to keep the most common 90%
8
+ of code short::
9
+
10
+ from ellements.core import LLMClient, Conversation, Tool, ToolRegistry
11
+
12
+ Subpackage map:
13
+
14
+ * :mod:`ellements.core.caching` — caching wrapper + backends
15
+ (``CachingLLMClient``, ``InMemoryCache``, ``JsonDiskCache``).
16
+ * :mod:`ellements.core.rate_limit` — rate-limit wrapper + token-bucket
17
+ implementation (``RateLimitedLLMClient``, ``TokenBucketRateLimiter``).
18
+ * :mod:`ellements.core.budgeting` — budget tracker + budgeted wrapper
19
+ (``BudgetedLLMClient``, ``CallCountBudget``, ``TokenBudget``).
20
+ * :mod:`ellements.core.observability` — event types, observers,
21
+ Markdown formatters (``LLMRequestEvent``, ``LLMResponseEvent``,
22
+ ``AgentEvent``, ``format_log_markdown``).
23
+ * :mod:`ellements.core.prompting` — persona / guideline / prompt
24
+ context (``PromptContext``, ``PersonaSource``, ``GuidelineSource``).
25
+ * :mod:`ellements.core.templating` — Mustache template renderer plus
26
+ ``render_prompt_template`` convenience helper.
27
+ * :mod:`ellements.core.config` — JSON / TOML loaders + ``overlay``.
28
+ * :mod:`ellements.core.chunking` — text chunking + ``count_tokens``.
29
+ * :mod:`ellements.core.async_utils` — ``parallel_map``.
30
+ * :mod:`ellements.core.tools` — full tool / dialect / registry surface,
31
+ including the provider dialects (``OpenAIChatDialect``,
32
+ ``OpenAIResponsesDialect``, ``AnthropicDialect``, ``GeminiDialect``,
33
+ ``default_dialect_for_model``).
34
+ * :mod:`ellements.core.llm` — extras like multimodal types
35
+ (``ImageInput``, ``ImageURLPart``, ``ImageGenerationResponse``,
36
+ ``GeneratedImage``) and the ``LLMClientWrapper`` base.
37
+ * :mod:`ellements.core.exceptions` — full exception hierarchy
38
+ (already re-exported below for the common ones; specialized
39
+ exceptions stay here).
40
+
41
+ Anything not listed in this module's ``__all__`` is reachable from its
42
+ subpackage. There are no hidden names: every subpackage carries its
43
+ own ``__all__``.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ from .exceptions import (
49
+ BudgetExceededError,
50
+ ConversationError,
51
+ EllementsError,
52
+ EllementsValidationError,
53
+ GuidelineNotFoundError,
54
+ LLMError,
55
+ LogprobsUnsupportedError,
56
+ MaxToolIterationsError,
57
+ PersonaNotFoundError,
58
+ PromptKeyMissingError,
59
+ PromptLibraryError,
60
+ StructuredOutputUnsupportedError,
61
+ )
62
+ from .llm import (
63
+ Conversation,
64
+ LLMClient,
65
+ LLMClientProtocol,
66
+ Message,
67
+ MessageContent,
68
+ MessageInput,
69
+ )
70
+ from .observability import JsonlPromptLogger, LLMObserver
71
+ from .prompting import GuidelineLibrary, PersonaLibrary
72
+ from .templating import TemplateRenderer
73
+ from .tools import (
74
+ SimpleTool,
75
+ Tool,
76
+ ToolCallRecord,
77
+ ToolCallResponse,
78
+ ToolRegistry,
79
+ ToolSpec,
80
+ )
81
+
82
+ __all__ = [
83
+ "BudgetExceededError",
84
+ "Conversation",
85
+ "ConversationError",
86
+ "EllementsError",
87
+ "EllementsValidationError",
88
+ "GuidelineLibrary",
89
+ "GuidelineNotFoundError",
90
+ "JsonlPromptLogger",
91
+ "LLMClient",
92
+ "LLMClientProtocol",
93
+ "LLMError",
94
+ "LLMObserver",
95
+ "LogprobsUnsupportedError",
96
+ "MaxToolIterationsError",
97
+ "Message",
98
+ "MessageContent",
99
+ "MessageInput",
100
+ "PersonaLibrary",
101
+ "PersonaNotFoundError",
102
+ "PromptKeyMissingError",
103
+ "PromptLibraryError",
104
+ "SimpleTool",
105
+ "StructuredOutputUnsupportedError",
106
+ "TemplateRenderer",
107
+ "Tool",
108
+ "ToolCallRecord",
109
+ "ToolCallResponse",
110
+ "ToolRegistry",
111
+ "ToolSpec",
112
+ ]
@@ -0,0 +1,42 @@
1
+ """Async helpers for bounded parallel work."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable, Callable, Iterable
7
+ from typing import TypeVar, cast
8
+
9
+ T = TypeVar("T")
10
+ R = TypeVar("R")
11
+
12
+
13
+ async def parallel_map(
14
+ items: Iterable[T],
15
+ worker: Callable[[T], Awaitable[R]],
16
+ *,
17
+ max_concurrency: int = 3,
18
+ ) -> list[R]:
19
+ """Apply an async worker to items with bounded concurrency.
20
+
21
+ Results preserve the input order even when items finish out of order.
22
+ If any worker raises, the remaining in-flight tasks are cancelled.
23
+ """
24
+ if max_concurrency < 1:
25
+ raise ValueError("max_concurrency must be at least 1")
26
+
27
+ item_list = list(items)
28
+ if not item_list:
29
+ return []
30
+
31
+ semaphore = asyncio.Semaphore(max_concurrency)
32
+ results: list[R | None] = [None] * len(item_list)
33
+
34
+ async def _run(index: int, item: T) -> None:
35
+ async with semaphore:
36
+ results[index] = await worker(item)
37
+
38
+ async with asyncio.TaskGroup() as task_group:
39
+ for index, item in enumerate(item_list):
40
+ task_group.create_task(_run(index, item))
41
+
42
+ return [cast(R, result) for result in results]
@@ -0,0 +1,43 @@
1
+ """Optional cost-tracking layer for :class:`LLMClient`.
2
+
3
+ This subpackage lets callers cap how much an :class:`LLMClient` is
4
+ allowed to spend before requests start failing fast — a defensive
5
+ measure for evaluation runs, agent loops, and any code path that might
6
+ otherwise burn through tokens without bound.
7
+
8
+ The design mirrors :mod:`ellements.core.caching` and
9
+ :mod:`ellements.core.rate_limit`:
10
+
11
+ * :class:`BudgetTrackerProtocol` — a structural :class:`typing.Protocol`
12
+ callers can satisfy with their own bookkeeping.
13
+ * :class:`CallCountBudget` — a tiny tracker that counts requests.
14
+ * :class:`TokenBudget` — accounts for input and output tokens against
15
+ a per-model price table (cost units are caller-defined; treat them
16
+ as USD or anything else consistent).
17
+ * :class:`BudgetedLLMClient` — composition wrapper that satisfies
18
+ :class:`~ellements.core.llm.LLMClientProtocol`, charging the active
19
+ budget around each call and raising
20
+ :class:`~ellements.core.exceptions.BudgetExceededError` when the cap
21
+ is hit.
22
+
23
+ Costs are advisory and apply only to ``complete``, ``complete_structured``,
24
+ ``complete_with_tools``, ``continue_conversation``, ``loglikelihood``,
25
+ and ``generate_image``. Streaming output is forwarded uncharged — the
26
+ provider's token counts are unavailable until the stream completes and
27
+ we deliberately keep this layer simple. Tag streaming usage manually
28
+ via :meth:`BudgetTrackerProtocol.charge` if you need it.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from .client import BudgetedLLMClient
34
+ from .protocol import BudgetTrackerProtocol
35
+ from .trackers import CallCountBudget, TokenBudget, TokenPrice
36
+
37
+ __all__ = [
38
+ "BudgetTrackerProtocol",
39
+ "BudgetedLLMClient",
40
+ "CallCountBudget",
41
+ "TokenBudget",
42
+ "TokenPrice",
43
+ ]
@@ -0,0 +1,276 @@
1
+ """Wrapping :class:`LLMClient` for budget-aware operation.
2
+
3
+ The wrapper enforces a *predictable* cost per method (the caller's
4
+ estimate, not provider-reported tokens) for two reasons:
5
+
6
+ 1. Many callers want a hard ceiling on requests where the unit is
7
+ simply "number of calls". :class:`CallCountBudget` covers that.
8
+ 2. Provider-reported token usage only arrives *after* a response, so
9
+ token-accurate budgeting cannot gate a call before it is made. For
10
+ that workflow, attach a :class:`TokenBudget` as an observer on the
11
+ wrapped :class:`LLMClient` directly — see
12
+ :meth:`ellements.core.budgeting.TokenBudget.charge` and the
13
+ :class:`~ellements.core.observability.LLMResponseEvent`'s ``usage``
14
+ field, which carries provider-reported token counts.
15
+
16
+ The wrapper's job is the pre-call gate and the post-call accounting
17
+ of the *flat* estimate; combining the two layers gives both fast
18
+ failure and exact accounting.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import AsyncIterator, Iterable, Mapping
24
+ from typing import TYPE_CHECKING, Any, TypeVar
25
+
26
+ from ..exceptions import BudgetExceededError
27
+ from ..llm.wrapper import LLMClientWrapper
28
+ from .protocol import BudgetTrackerProtocol
29
+
30
+ if TYPE_CHECKING:
31
+ from pydantic import BaseModel
32
+
33
+ from ..llm.images import ImageGenerationResponse
34
+ from ..llm.messages import Conversation, MessageInput
35
+ from ..llm.protocol import LLMClientProtocol
36
+ from ..tools import ToolCallResponse, ToolDialect, ToolExecutor, ToolRegistry
37
+
38
+ T = TypeVar("T", bound="BaseModel")
39
+
40
+
41
+ class BudgetedLLMClient(LLMClientWrapper):
42
+ """Decorate an LLM client so spend is tracked against a budget.
43
+
44
+ Args:
45
+ inner: The wrapped LLM client. Must satisfy
46
+ :class:`~ellements.core.llm.LLMClientProtocol`.
47
+ tracker: The :class:`BudgetTrackerProtocol` to consult before
48
+ and after each call.
49
+ cost_per_method: Optional override of the flat cost charged for
50
+ specific methods. Keys are method names (``"complete"``,
51
+ ``"complete_structured"``, ``"complete_with_tools"``,
52
+ ``"continue_conversation"``, ``"loglikelihood"``,
53
+ ``"generate_image"``). Methods not present use
54
+ ``default_cost``.
55
+ default_cost: Cost charged for any method without an explicit
56
+ override. Defaults to ``1.0``.
57
+
58
+ Streams (:meth:`stream`) are forwarded uncharged because their
59
+ token usage is only known after the stream completes. If you need
60
+ streaming inside a budget, charge it yourself once the stream is
61
+ drained.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ *,
67
+ inner: LLMClientProtocol,
68
+ tracker: BudgetTrackerProtocol,
69
+ cost_per_method: dict[str, float] | None = None,
70
+ default_cost: float = 1.0,
71
+ ) -> None:
72
+ super().__init__(inner=inner)
73
+ self._tracker = tracker
74
+ self._cost_per_method = dict(cost_per_method or {})
75
+ self._default_cost = float(default_cost)
76
+
77
+ @property
78
+ def tracker(self) -> BudgetTrackerProtocol:
79
+ """The active budget tracker (useful for inspection or reset)."""
80
+ return self._tracker
81
+
82
+ def _cost_for(self, method: str) -> float:
83
+ """Resolve the configured cost for ``method``."""
84
+ return self._cost_per_method.get(method, self._default_cost)
85
+
86
+ def _gate(self, method: str) -> float:
87
+ """Pre-call check; returns the cost we plan to charge.
88
+
89
+ Raises:
90
+ BudgetExceededError: When :meth:`BudgetTrackerProtocol.can_afford`
91
+ rejects the projected cost.
92
+ """
93
+ cost = self._cost_for(method)
94
+ if not self._tracker.can_afford(cost):
95
+ remaining = self._tracker.remaining()
96
+ raise BudgetExceededError(
97
+ f"budget exhausted before {method!r} "
98
+ f"(remaining={remaining:.4f}, attempted={cost:.4f})",
99
+ limit=remaining + cost,
100
+ spent=remaining,
101
+ attempted=cost,
102
+ )
103
+ return cost
104
+
105
+ async def complete(
106
+ self,
107
+ messages: MessageInput,
108
+ *,
109
+ model: str | None = None,
110
+ temperature: float = 0.7,
111
+ max_tokens: int | None = None,
112
+ **kwargs: Any,
113
+ ) -> str:
114
+ """Gate on the budget, run ``complete``, then charge the flat cost."""
115
+ cost = self._gate("complete")
116
+ result = await self._inner.complete(
117
+ messages,
118
+ model=model,
119
+ temperature=temperature,
120
+ max_tokens=max_tokens,
121
+ **kwargs,
122
+ )
123
+ self._tracker.charge(cost, details={"method": "complete"})
124
+ return result
125
+
126
+ async def complete_structured(
127
+ self,
128
+ messages: MessageInput,
129
+ response_model: type[T],
130
+ *,
131
+ model: str | None = None,
132
+ temperature: float = 0.7,
133
+ max_tokens: int | None = None,
134
+ **kwargs: Any,
135
+ ) -> T:
136
+ """Gate on the budget, run ``complete_structured``, then charge."""
137
+ cost = self._gate("complete_structured")
138
+ result = await self._inner.complete_structured(
139
+ messages,
140
+ response_model,
141
+ model=model,
142
+ temperature=temperature,
143
+ max_tokens=max_tokens,
144
+ **kwargs,
145
+ )
146
+ self._tracker.charge(cost, details={"method": "complete_structured"})
147
+ return result
148
+
149
+ async def complete_with_tools(
150
+ self,
151
+ messages: MessageInput,
152
+ tools: ToolRegistry | Mapping[str, Any] | Iterable[Any],
153
+ *,
154
+ tool_executor: ToolExecutor | None = None,
155
+ dialect: ToolDialect | None = None,
156
+ model: str | None = None,
157
+ temperature: float = 0.7,
158
+ max_tokens: int | None = None,
159
+ max_iterations: int = 10,
160
+ **kwargs: Any,
161
+ ) -> ToolCallResponse:
162
+ """Charge once for the whole tool-using turn.
163
+
164
+ Internal tool-iteration calls made by the inner client are not
165
+ re-billed here; the cost reflects "one user-facing turn".
166
+ """
167
+ cost = self._gate("complete_with_tools")
168
+ result = await self._inner.complete_with_tools(
169
+ messages,
170
+ tools,
171
+ tool_executor=tool_executor,
172
+ dialect=dialect,
173
+ model=model,
174
+ temperature=temperature,
175
+ max_tokens=max_tokens,
176
+ max_iterations=max_iterations,
177
+ **kwargs,
178
+ )
179
+ self._tracker.charge(cost, details={"method": "complete_with_tools"})
180
+ return result
181
+
182
+ def stream(
183
+ self,
184
+ messages: MessageInput,
185
+ *,
186
+ model: str | None = None,
187
+ temperature: float = 0.7,
188
+ max_tokens: int | None = None,
189
+ **kwargs: Any,
190
+ ) -> AsyncIterator[str]:
191
+ """Forward streamed completions to the inner client untouched.
192
+
193
+ Streams are not charged because their token usage is only
194
+ known after the stream completes. Charge yourself once the
195
+ stream is drained if you need streaming inside a budget.
196
+ """
197
+ return self._inner.stream(
198
+ messages,
199
+ model=model,
200
+ temperature=temperature,
201
+ max_tokens=max_tokens,
202
+ **kwargs,
203
+ )
204
+
205
+ async def continue_conversation(
206
+ self,
207
+ conversation: Conversation,
208
+ user_message: str,
209
+ *,
210
+ temperature: float = 0.7,
211
+ max_tokens: int | None = None,
212
+ **kwargs: Any,
213
+ ) -> str:
214
+ """Gate on the budget per turn, then forward."""
215
+ cost = self._gate("continue_conversation")
216
+ result = await self._inner.continue_conversation(
217
+ conversation,
218
+ user_message,
219
+ temperature=temperature,
220
+ max_tokens=max_tokens,
221
+ **kwargs,
222
+ )
223
+ self._tracker.charge(cost, details={"method": "continue_conversation"})
224
+ return result
225
+
226
+ async def loglikelihood(
227
+ self,
228
+ context: str,
229
+ continuation: str,
230
+ *,
231
+ model: str | None = None,
232
+ max_tokens: int | None = None,
233
+ **kwargs: Any,
234
+ ) -> tuple[float, bool]:
235
+ """Gate on the budget, run ``loglikelihood``, then charge."""
236
+ cost = self._gate("loglikelihood")
237
+ result = await self._inner.loglikelihood(
238
+ context,
239
+ continuation,
240
+ model=model,
241
+ max_tokens=max_tokens,
242
+ **kwargs,
243
+ )
244
+ self._tracker.charge(cost, details={"method": "loglikelihood"})
245
+ return result
246
+
247
+ async def generate_image(
248
+ self,
249
+ prompt: str,
250
+ *,
251
+ model: str | None = None,
252
+ n: int = 1,
253
+ size: str | None = None,
254
+ quality: str | None = None,
255
+ style: str | None = None,
256
+ response_format: str = "url",
257
+ **kwargs: Any,
258
+ ) -> ImageGenerationResponse:
259
+ """Gate on the budget, run ``generate_image``, then charge.
260
+
261
+ Pass a higher cost via ``cost_per_method={"generate_image": …}``
262
+ to reflect the typically larger per-call price of images.
263
+ """
264
+ cost = self._gate("generate_image")
265
+ result = await self._inner.generate_image(
266
+ prompt,
267
+ model=model,
268
+ n=n,
269
+ size=size,
270
+ quality=quality,
271
+ style=style,
272
+ response_format=response_format,
273
+ **kwargs,
274
+ )
275
+ self._tracker.charge(cost, details={"method": "generate_image"})
276
+ return result
@@ -0,0 +1,51 @@
1
+ """Protocol defining how a budget tracker is consumed by wrappers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Protocol, runtime_checkable
6
+
7
+
8
+ @runtime_checkable
9
+ class BudgetTrackerProtocol(Protocol):
10
+ """Structural type for cost trackers consumed by :class:`BudgetedLLMClient`.
11
+
12
+ Implementations are expected to be lightweight, in-process bookkeepers.
13
+ They are *not* concurrency-safe across multiple processes; if you need
14
+ cross-process budgets, persist spend yourself and rebuild the tracker
15
+ on startup.
16
+
17
+ Methods
18
+ -------
19
+ can_afford
20
+ Predicate evaluated *before* a request is dispatched. Trackers
21
+ that gate on call count or a hard limit should refuse early
22
+ rather than after the spend has occurred. ``estimate`` is the
23
+ caller's best guess at the upcoming cost; trackers may ignore
24
+ it.
25
+ charge
26
+ Records actual spend after a request completes. ``details`` is
27
+ a free-form mapping (e.g. ``{"input_tokens": 42, "output_tokens": 11,
28
+ "model": "openai/gpt-4o"}``) so trackers can compute the true
29
+ cost from raw provider usage data when an estimate is not enough.
30
+ Must raise :class:`~ellements.core.exceptions.BudgetExceededError`
31
+ when the recorded charge pushes spend past the configured cap.
32
+ remaining
33
+ Returns the headroom left under the cap, or ``math.inf`` for
34
+ unlimited trackers. Useful for diagnostics and for callers that
35
+ want to short-circuit large batches before they start.
36
+ """
37
+
38
+ def can_afford(self, estimate: float = 0.0) -> bool:
39
+ """Return ``True`` if ``estimate`` cost is plausibly affordable."""
40
+ ...
41
+
42
+ def charge(self, cost: float, *, details: dict[str, Any] | None = None) -> None:
43
+ """Record ``cost`` against the budget, raising if the cap is exceeded."""
44
+ ...
45
+
46
+ def remaining(self) -> float:
47
+ """Return the unspent portion of the budget (``math.inf`` if uncapped)."""
48
+ ...
49
+
50
+
51
+ __all__ = ["BudgetTrackerProtocol"]