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,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"]
|