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,177 @@
|
|
|
1
|
+
"""Concrete :class:`BudgetTrackerProtocol` implementations.
|
|
2
|
+
|
|
3
|
+
Two strategies are provided out of the box:
|
|
4
|
+
|
|
5
|
+
* :class:`CallCountBudget` — caps the *number* of LLM calls. Useful for
|
|
6
|
+
test harnesses where token counts are unpredictable but you want a
|
|
7
|
+
hard ceiling on traffic.
|
|
8
|
+
|
|
9
|
+
* :class:`TokenBudget` — accounts for input and output tokens against
|
|
10
|
+
a per-model price table. The unit of the price is up to the caller
|
|
11
|
+
(USD, micro-dollars, abstract "credits") as long as it is consistent
|
|
12
|
+
across the price table and the cap.
|
|
13
|
+
|
|
14
|
+
Both trackers are thread-/coroutine-safe within a single event loop
|
|
15
|
+
because they perform whole-method updates under no contention assumptions
|
|
16
|
+
(LLM calls are coarse-grained). If you need stricter guarantees, wrap
|
|
17
|
+
them in an :class:`asyncio.Lock` at the call site.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from ..exceptions import BudgetExceededError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class TokenPrice:
|
|
30
|
+
"""Price per token for a given model, split by direction.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
input: Cost per input token (units defined by the caller).
|
|
34
|
+
output: Cost per output token.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
input: float
|
|
38
|
+
output: float
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class CallCountBudget:
|
|
43
|
+
"""Bound the total number of completed LLM calls.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
limit: Maximum number of charges accepted before subsequent
|
|
47
|
+
:meth:`charge` invocations raise. Must be positive.
|
|
48
|
+
|
|
49
|
+
Each :meth:`charge` increments ``spent`` by one regardless of the
|
|
50
|
+
``cost`` argument; the tracker treats every call as identical.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
limit: int
|
|
54
|
+
spent: int = 0
|
|
55
|
+
|
|
56
|
+
def __post_init__(self) -> None:
|
|
57
|
+
if self.limit <= 0:
|
|
58
|
+
raise ValueError("limit must be positive")
|
|
59
|
+
|
|
60
|
+
def can_afford(self, estimate: float = 0.0) -> bool:
|
|
61
|
+
"""Return ``True`` while we have room for at least one more call."""
|
|
62
|
+
return self.spent < self.limit
|
|
63
|
+
|
|
64
|
+
def charge(self, cost: float, *, details: dict[str, Any] | None = None) -> None:
|
|
65
|
+
"""Record one call against the budget.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
cost: Ignored — kept for protocol compatibility.
|
|
69
|
+
details: Ignored — kept for protocol compatibility.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
BudgetExceededError: When the call count has already hit
|
|
73
|
+
``limit`` (i.e. this call would push it over).
|
|
74
|
+
"""
|
|
75
|
+
if self.spent >= self.limit:
|
|
76
|
+
raise BudgetExceededError(
|
|
77
|
+
f"call count budget exhausted ({self.spent}/{self.limit})",
|
|
78
|
+
limit=float(self.limit),
|
|
79
|
+
spent=float(self.spent),
|
|
80
|
+
attempted=1.0,
|
|
81
|
+
)
|
|
82
|
+
self.spent += 1
|
|
83
|
+
|
|
84
|
+
def remaining(self) -> float:
|
|
85
|
+
"""Return the number of calls left before the cap is hit."""
|
|
86
|
+
return float(self.limit - self.spent)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(slots=True)
|
|
90
|
+
class TokenBudget:
|
|
91
|
+
"""Bound cumulative token spend across one or more models.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
limit: Maximum cumulative cost. Caller-defined units.
|
|
95
|
+
prices: Mapping of model name to :class:`TokenPrice`. Models
|
|
96
|
+
not present in the table are charged the ``default_price``.
|
|
97
|
+
If neither is set, calls with unknown models raise.
|
|
98
|
+
default_price: Fallback price used when a charged ``model`` is
|
|
99
|
+
not in ``prices``. ``None`` means "fail on unknown models".
|
|
100
|
+
|
|
101
|
+
Attributes:
|
|
102
|
+
spent: Running total of charges accumulated through :meth:`charge`.
|
|
103
|
+
|
|
104
|
+
Notes:
|
|
105
|
+
The tracker assumes ``details`` carries integer ``input_tokens``
|
|
106
|
+
and ``output_tokens`` plus a ``model`` string. Missing keys
|
|
107
|
+
default to ``0`` tokens, which means unaccounted calls cost
|
|
108
|
+
nothing but still count toward provider rate-limits.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
limit: float
|
|
112
|
+
prices: dict[str, TokenPrice] = field(default_factory=dict)
|
|
113
|
+
default_price: TokenPrice | None = None
|
|
114
|
+
spent: float = 0.0
|
|
115
|
+
|
|
116
|
+
def __post_init__(self) -> None:
|
|
117
|
+
if self.limit <= 0:
|
|
118
|
+
raise ValueError("limit must be positive")
|
|
119
|
+
|
|
120
|
+
def can_afford(self, estimate: float = 0.0) -> bool:
|
|
121
|
+
"""Return ``True`` while ``spent + estimate`` is within the cap."""
|
|
122
|
+
return self.spent + max(estimate, 0.0) <= self.limit
|
|
123
|
+
|
|
124
|
+
def charge(self, cost: float, *, details: dict[str, Any] | None = None) -> None:
|
|
125
|
+
"""Record token-derived spend against the budget.
|
|
126
|
+
|
|
127
|
+
The actual charge is computed from ``details`` when it carries
|
|
128
|
+
``input_tokens`` (and optionally ``output_tokens`` plus
|
|
129
|
+
``model``) so providers' usage data drives the bill. Otherwise
|
|
130
|
+
the supplied ``cost`` is used as a flat fallback — this is the
|
|
131
|
+
path taken by :class:`BudgetedLLMClient`, which knows the
|
|
132
|
+
per-method estimate but not the exact token usage.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
cost: Flat fallback charge applied when ``details`` lacks
|
|
136
|
+
token counts.
|
|
137
|
+
details: Optional mapping. When it contains an
|
|
138
|
+
``input_tokens`` key the charge is computed as
|
|
139
|
+
``input_tokens * price.input + output_tokens * price.output``
|
|
140
|
+
using ``prices[details["model"]]`` (falling back to
|
|
141
|
+
``default_price``); otherwise ``details`` is ignored
|
|
142
|
+
for the math.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
BudgetExceededError: When the new charge would push ``spent``
|
|
146
|
+
past ``limit``.
|
|
147
|
+
KeyError: When ``details`` carries token counts for a model
|
|
148
|
+
that is not in ``prices`` and ``default_price`` is unset.
|
|
149
|
+
"""
|
|
150
|
+
if details is None or "input_tokens" not in details:
|
|
151
|
+
charge = max(cost, 0.0)
|
|
152
|
+
else:
|
|
153
|
+
input_tokens = int(details.get("input_tokens", 0))
|
|
154
|
+
output_tokens = int(details.get("output_tokens", 0))
|
|
155
|
+
model = str(details.get("model", ""))
|
|
156
|
+
price = self.prices.get(model, self.default_price)
|
|
157
|
+
if price is None:
|
|
158
|
+
raise KeyError(
|
|
159
|
+
f"no price configured for model {model!r}; set default_price "
|
|
160
|
+
"or add it to prices"
|
|
161
|
+
)
|
|
162
|
+
charge = input_tokens * price.input + output_tokens * price.output
|
|
163
|
+
|
|
164
|
+
projected = self.spent + charge
|
|
165
|
+
if projected > self.limit:
|
|
166
|
+
raise BudgetExceededError(
|
|
167
|
+
f"token budget exhausted "
|
|
168
|
+
f"(spent={self.spent:.4f}, attempted={charge:.4f}, limit={self.limit:.4f})",
|
|
169
|
+
limit=self.limit,
|
|
170
|
+
spent=self.spent,
|
|
171
|
+
attempted=charge,
|
|
172
|
+
)
|
|
173
|
+
self.spent = projected
|
|
174
|
+
|
|
175
|
+
def remaining(self) -> float:
|
|
176
|
+
"""Return the unspent portion of the budget (clipped at zero)."""
|
|
177
|
+
return max(self.limit - self.spent, 0.0)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Optional caching layer for LLM calls.
|
|
2
|
+
|
|
3
|
+
This subpackage offers three orthogonal pieces:
|
|
4
|
+
|
|
5
|
+
1. :class:`Cache` — a tiny async Protocol with ``get`` / ``set`` /
|
|
6
|
+
``delete`` / ``clear`` semantics. Anything that satisfies it can be
|
|
7
|
+
plugged into the rest of the package.
|
|
8
|
+
2. Concrete backends: :class:`InMemoryCache` (process-local dict with
|
|
9
|
+
optional TTL) and :class:`JsonDiskCache` (one JSON file per key in
|
|
10
|
+
a directory; survives process restarts).
|
|
11
|
+
3. :class:`CachingLLMClient` — a thin wrapper around any
|
|
12
|
+
:class:`ellements.core.LLMClientProtocol` that memoizes
|
|
13
|
+
:meth:`complete` and :meth:`complete_structured` responses keyed
|
|
14
|
+
off the canonical request payload.
|
|
15
|
+
|
|
16
|
+
Caching is **opt-in**: nothing in ``ellements.core.LLMClient`` itself
|
|
17
|
+
caches. Apps wrap their client when they want repeatable, cheap
|
|
18
|
+
responses (smoke tests, demos, deterministic CI). Streams, tool calls,
|
|
19
|
+
and image generation are deliberately **not** cached — they have
|
|
20
|
+
side-effects or unbounded outputs that don't map cleanly onto a
|
|
21
|
+
key→value model.
|
|
22
|
+
|
|
23
|
+
Example::
|
|
24
|
+
|
|
25
|
+
from ellements.core import LLMClient
|
|
26
|
+
from ellements.core.caching import CachingLLMClient, InMemoryCache
|
|
27
|
+
|
|
28
|
+
client = CachingLLMClient(
|
|
29
|
+
inner=LLMClient(model="openai/gpt-4o-mini"),
|
|
30
|
+
cache=InMemoryCache(default_ttl=3600),
|
|
31
|
+
)
|
|
32
|
+
answer1 = await client.complete("Hello?") # hits the network
|
|
33
|
+
answer2 = await client.complete("Hello?") # served from cache
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from .cache import Cache, CacheEntry
|
|
39
|
+
from .client import CachingLLMClient
|
|
40
|
+
from .disk import JsonDiskCache
|
|
41
|
+
from .keys import build_cache_key
|
|
42
|
+
from .memory import InMemoryCache
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"Cache",
|
|
46
|
+
"CacheEntry",
|
|
47
|
+
"CachingLLMClient",
|
|
48
|
+
"InMemoryCache",
|
|
49
|
+
"JsonDiskCache",
|
|
50
|
+
"build_cache_key",
|
|
51
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Cache Protocol and shared value type."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class CacheEntry:
|
|
11
|
+
"""A single cache hit.
|
|
12
|
+
|
|
13
|
+
Backends return :data:`None` on a miss and an :class:`CacheEntry`
|
|
14
|
+
on a hit so callers never have to disambiguate "cached ``None``"
|
|
15
|
+
from "absent". The dataclass is frozen + slotted to keep cache
|
|
16
|
+
handling allocation-light.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
value: The cached value. May be any JSON-serializable object
|
|
20
|
+
(or any object the backend can round-trip — e.g. an
|
|
21
|
+
in-memory backend accepts anything picklable / addressable
|
|
22
|
+
by reference).
|
|
23
|
+
created_at: Wall-clock timestamp (Unix seconds) when the entry
|
|
24
|
+
was first written. Useful for callers that want to expose
|
|
25
|
+
cache age in UIs.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
value: Any
|
|
29
|
+
created_at: float
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@runtime_checkable
|
|
33
|
+
class Cache(Protocol):
|
|
34
|
+
"""Async-safe key→value cache.
|
|
35
|
+
|
|
36
|
+
Implementations must tolerate concurrent ``get`` / ``set`` calls
|
|
37
|
+
from many tasks. Backends are free to interpret keys as opaque
|
|
38
|
+
strings (the in-package helpers always produce stable, hashed
|
|
39
|
+
keys via :func:`build_cache_key`).
|
|
40
|
+
|
|
41
|
+
The TTL on :meth:`set` is **per-entry**: when present it overrides
|
|
42
|
+
any default TTL the backend may have. When ``ttl`` is ``None`` the
|
|
43
|
+
backend's default policy applies (which may be "never expire").
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
async def get(self, key: str) -> CacheEntry | None:
|
|
47
|
+
"""Return the entry stored at *key*, or ``None`` on a miss."""
|
|
48
|
+
|
|
49
|
+
async def set(
|
|
50
|
+
self,
|
|
51
|
+
key: str,
|
|
52
|
+
value: Any,
|
|
53
|
+
*,
|
|
54
|
+
ttl: float | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Store *value* under *key*.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
key: Opaque key. Treated as a string by every backend.
|
|
60
|
+
value: Object to store. Backends may impose serialization
|
|
61
|
+
constraints (e.g. JSON-only).
|
|
62
|
+
ttl: Optional time-to-live in seconds. When omitted the
|
|
63
|
+
backend's default is used.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
async def delete(self, key: str) -> None:
|
|
67
|
+
"""Remove the entry at *key* if it exists. No-op on miss."""
|
|
68
|
+
|
|
69
|
+
async def clear(self) -> None:
|
|
70
|
+
"""Drop every entry held by the backend."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
__all__ = ["Cache", "CacheEntry"]
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Caching wrapper around any :class:`LLMClientProtocol`.
|
|
2
|
+
|
|
3
|
+
Wraps an inner LLM client and memoizes deterministic calls
|
|
4
|
+
(:meth:`complete`, :meth:`complete_structured`). Streams, tool calls,
|
|
5
|
+
``continue_conversation``, ``loglikelihood``, and ``generate_image``
|
|
6
|
+
are **not** cached and pass straight through — they either have
|
|
7
|
+
side-effects (tool calls, conversation mutation) or produce outputs
|
|
8
|
+
that can't be cleanly serialized into a key→value store (streams,
|
|
9
|
+
images).
|
|
10
|
+
|
|
11
|
+
The wrapper itself satisfies :class:`LLMClientProtocol`, so it slots
|
|
12
|
+
into anything that consumes a client structurally.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import AsyncIterator, Iterable, Mapping
|
|
18
|
+
from typing import Any, TypeVar
|
|
19
|
+
|
|
20
|
+
from ..llm.images import ImageGenerationResponse
|
|
21
|
+
from ..llm.messages import MessageInput, normalize_message_input
|
|
22
|
+
from ..llm.protocol import LLMClientProtocol
|
|
23
|
+
from ..llm.wrapper import LLMClientWrapper
|
|
24
|
+
from ..tools import ToolDialect, ToolExecutor, ToolRegistry
|
|
25
|
+
from ..tools.records import ToolCallResponse
|
|
26
|
+
from .cache import Cache
|
|
27
|
+
from .keys import build_cache_key
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CachingLLMClient(LLMClientWrapper):
|
|
33
|
+
"""LLM client wrapper that memoizes deterministic completions.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
inner: Any object satisfying :class:`LLMClientProtocol`. All
|
|
37
|
+
uncached methods delegate to it unchanged.
|
|
38
|
+
cache: Backend storing the cached responses. Any
|
|
39
|
+
:class:`Cache` implementation works.
|
|
40
|
+
ttl: Optional time-to-live (seconds) applied to every cache
|
|
41
|
+
write made by this wrapper. ``None`` defers to the
|
|
42
|
+
backend's policy.
|
|
43
|
+
only_cache_low_temperature: When True (the default), only
|
|
44
|
+
cache calls with ``temperature == 0.0`` — i.e. requests
|
|
45
|
+
that are deterministic by construction. Set to False to
|
|
46
|
+
cache everything (useful when callers know the model's
|
|
47
|
+
seed/decoding policy is stable).
|
|
48
|
+
|
|
49
|
+
The wrapper exposes ``inner``, ``cache``, and ``model`` as
|
|
50
|
+
attributes so callers can introspect or replace pieces without
|
|
51
|
+
rebuilding the wrapper. ``model`` proxies the inner client's
|
|
52
|
+
default model and satisfies :class:`LLMClientProtocol`.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
inner: LLMClientProtocol,
|
|
59
|
+
cache: Cache,
|
|
60
|
+
ttl: float | None = None,
|
|
61
|
+
only_cache_low_temperature: bool = True,
|
|
62
|
+
) -> None:
|
|
63
|
+
super().__init__(inner=inner)
|
|
64
|
+
self.cache = cache
|
|
65
|
+
self.ttl = ttl
|
|
66
|
+
self.only_cache_low_temperature = only_cache_low_temperature
|
|
67
|
+
|
|
68
|
+
# ── Cached methods ────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
async def complete(
|
|
71
|
+
self,
|
|
72
|
+
messages: MessageInput,
|
|
73
|
+
*,
|
|
74
|
+
model: str | None = None,
|
|
75
|
+
temperature: float = 0.7,
|
|
76
|
+
max_tokens: int | None = None,
|
|
77
|
+
**kwargs: Any,
|
|
78
|
+
) -> str:
|
|
79
|
+
"""Return a cached completion when available; otherwise call
|
|
80
|
+
through and cache the result.
|
|
81
|
+
|
|
82
|
+
Bypasses the cache when ``only_cache_low_temperature`` is True
|
|
83
|
+
and ``temperature > 0`` (non-deterministic). See
|
|
84
|
+
:meth:`LLMClientProtocol.complete` for the underlying contract.
|
|
85
|
+
"""
|
|
86
|
+
if not self._should_cache(temperature):
|
|
87
|
+
return await self._inner.complete(
|
|
88
|
+
messages,
|
|
89
|
+
model=model,
|
|
90
|
+
temperature=temperature,
|
|
91
|
+
max_tokens=max_tokens,
|
|
92
|
+
**kwargs,
|
|
93
|
+
)
|
|
94
|
+
resolved_model = model or self._inner.model
|
|
95
|
+
key = build_cache_key(
|
|
96
|
+
method="complete",
|
|
97
|
+
model=resolved_model,
|
|
98
|
+
messages=_messages_payload(messages),
|
|
99
|
+
temperature=temperature,
|
|
100
|
+
max_tokens=max_tokens,
|
|
101
|
+
extra=kwargs,
|
|
102
|
+
)
|
|
103
|
+
hit = await self.cache.get(key)
|
|
104
|
+
if hit is not None and isinstance(hit.value, str):
|
|
105
|
+
return hit.value
|
|
106
|
+
result = await self._inner.complete(
|
|
107
|
+
messages,
|
|
108
|
+
model=model,
|
|
109
|
+
temperature=temperature,
|
|
110
|
+
max_tokens=max_tokens,
|
|
111
|
+
**kwargs,
|
|
112
|
+
)
|
|
113
|
+
await self.cache.set(key, result, ttl=self.ttl)
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
async def complete_structured(
|
|
117
|
+
self,
|
|
118
|
+
messages: MessageInput,
|
|
119
|
+
response_model: type[T],
|
|
120
|
+
*,
|
|
121
|
+
model: str | None = None,
|
|
122
|
+
temperature: float = 0.7,
|
|
123
|
+
max_tokens: int | None = None,
|
|
124
|
+
**kwargs: Any,
|
|
125
|
+
) -> T:
|
|
126
|
+
"""Return a cached structured completion when available; otherwise
|
|
127
|
+
call through and cache the serialised result.
|
|
128
|
+
|
|
129
|
+
Cached values are stored as ``model_dump()`` payloads and
|
|
130
|
+
re-hydrated through *response_model* on read, so the cache is
|
|
131
|
+
immune to in-process model identity changes. See
|
|
132
|
+
:meth:`LLMClientProtocol.complete_structured` for the
|
|
133
|
+
underlying contract.
|
|
134
|
+
"""
|
|
135
|
+
if not self._should_cache(temperature):
|
|
136
|
+
return await self._inner.complete_structured(
|
|
137
|
+
messages,
|
|
138
|
+
response_model,
|
|
139
|
+
model=model,
|
|
140
|
+
temperature=temperature,
|
|
141
|
+
max_tokens=max_tokens,
|
|
142
|
+
**kwargs,
|
|
143
|
+
)
|
|
144
|
+
resolved_model = model or self._inner.model
|
|
145
|
+
key = build_cache_key(
|
|
146
|
+
method="complete_structured",
|
|
147
|
+
model=resolved_model,
|
|
148
|
+
messages=_messages_payload(messages),
|
|
149
|
+
temperature=temperature,
|
|
150
|
+
max_tokens=max_tokens,
|
|
151
|
+
extra=kwargs,
|
|
152
|
+
response_model=response_model,
|
|
153
|
+
)
|
|
154
|
+
hit = await self.cache.get(key)
|
|
155
|
+
if hit is not None:
|
|
156
|
+
if isinstance(hit.value, response_model):
|
|
157
|
+
return hit.value
|
|
158
|
+
if isinstance(hit.value, dict):
|
|
159
|
+
return response_model(**hit.value)
|
|
160
|
+
result = await self._inner.complete_structured(
|
|
161
|
+
messages,
|
|
162
|
+
response_model,
|
|
163
|
+
model=model,
|
|
164
|
+
temperature=temperature,
|
|
165
|
+
max_tokens=max_tokens,
|
|
166
|
+
**kwargs,
|
|
167
|
+
)
|
|
168
|
+
cacheable = result.model_dump() if hasattr(result, "model_dump") else result
|
|
169
|
+
await self.cache.set(key, cacheable, ttl=self.ttl)
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
# ── Uncached pass-through methods ─────────────────────────────────
|
|
173
|
+
#
|
|
174
|
+
# ``continue_conversation`` is inherited from LLMClientWrapper and
|
|
175
|
+
# forwarded unchanged. The remaining methods below are also pure
|
|
176
|
+
# passthroughs (no cache lookups would ever hit for stateful or
|
|
177
|
+
# non-serializable outputs), but they are explicit here so the
|
|
178
|
+
# type checker can see the LLMClientProtocol surface in full.
|
|
179
|
+
|
|
180
|
+
async def complete_with_tools(
|
|
181
|
+
self,
|
|
182
|
+
messages: MessageInput,
|
|
183
|
+
tools: ToolRegistry | Mapping[str, Any] | Iterable[Any],
|
|
184
|
+
*,
|
|
185
|
+
tool_executor: ToolExecutor | None = None,
|
|
186
|
+
dialect: ToolDialect | None = None,
|
|
187
|
+
model: str | None = None,
|
|
188
|
+
temperature: float = 0.7,
|
|
189
|
+
max_tokens: int | None = None,
|
|
190
|
+
max_iterations: int = 10,
|
|
191
|
+
**kwargs: Any,
|
|
192
|
+
) -> ToolCallResponse:
|
|
193
|
+
"""Forward tool-using completions to the inner client unchanged.
|
|
194
|
+
|
|
195
|
+
Tool executions have side-effects, so the wrapper does not
|
|
196
|
+
cache the response. See
|
|
197
|
+
:meth:`LLMClientProtocol.complete_with_tools`.
|
|
198
|
+
"""
|
|
199
|
+
return await self._inner.complete_with_tools(
|
|
200
|
+
messages,
|
|
201
|
+
tools,
|
|
202
|
+
tool_executor=tool_executor,
|
|
203
|
+
dialect=dialect,
|
|
204
|
+
model=model,
|
|
205
|
+
temperature=temperature,
|
|
206
|
+
max_tokens=max_tokens,
|
|
207
|
+
max_iterations=max_iterations,
|
|
208
|
+
**kwargs,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def stream(
|
|
212
|
+
self,
|
|
213
|
+
messages: MessageInput,
|
|
214
|
+
*,
|
|
215
|
+
model: str | None = None,
|
|
216
|
+
temperature: float = 0.7,
|
|
217
|
+
max_tokens: int | None = None,
|
|
218
|
+
**kwargs: Any,
|
|
219
|
+
) -> AsyncIterator[str]:
|
|
220
|
+
"""Forward streamed completions to the inner client unchanged.
|
|
221
|
+
|
|
222
|
+
Stream chunks cannot be cleanly re-played from a cache without
|
|
223
|
+
materialising the full sequence in memory; callers should
|
|
224
|
+
cache at a higher level if they need that. See
|
|
225
|
+
:meth:`LLMClientProtocol.stream`.
|
|
226
|
+
"""
|
|
227
|
+
return self._inner.stream(
|
|
228
|
+
messages,
|
|
229
|
+
model=model,
|
|
230
|
+
temperature=temperature,
|
|
231
|
+
max_tokens=max_tokens,
|
|
232
|
+
**kwargs,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
async def loglikelihood(
|
|
236
|
+
self,
|
|
237
|
+
context: str,
|
|
238
|
+
continuation: str,
|
|
239
|
+
*,
|
|
240
|
+
model: str | None = None,
|
|
241
|
+
max_tokens: int | None = None,
|
|
242
|
+
**kwargs: Any,
|
|
243
|
+
) -> tuple[float, bool]:
|
|
244
|
+
"""Forward log-likelihood scoring to the inner client unchanged.
|
|
245
|
+
|
|
246
|
+
Scoring is deterministic per (model, context, continuation),
|
|
247
|
+
but is not a frequent enough call to warrant a cache layer
|
|
248
|
+
here. See :meth:`LLMClientProtocol.loglikelihood`.
|
|
249
|
+
"""
|
|
250
|
+
return await self._inner.loglikelihood(
|
|
251
|
+
context,
|
|
252
|
+
continuation,
|
|
253
|
+
model=model,
|
|
254
|
+
max_tokens=max_tokens,
|
|
255
|
+
**kwargs,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
async def generate_image(
|
|
259
|
+
self,
|
|
260
|
+
prompt: str,
|
|
261
|
+
*,
|
|
262
|
+
model: str | None = None,
|
|
263
|
+
n: int = 1,
|
|
264
|
+
size: str | None = None,
|
|
265
|
+
quality: str | None = None,
|
|
266
|
+
style: str | None = None,
|
|
267
|
+
response_format: str = "url",
|
|
268
|
+
**kwargs: Any,
|
|
269
|
+
) -> ImageGenerationResponse:
|
|
270
|
+
"""Forward image generation to the inner client unchanged.
|
|
271
|
+
|
|
272
|
+
Image bytes / URLs are out of scope for a generic LLM cache;
|
|
273
|
+
callers should use a dedicated image cache if needed. See
|
|
274
|
+
:meth:`LLMClientProtocol.generate_image`.
|
|
275
|
+
"""
|
|
276
|
+
return await self._inner.generate_image(
|
|
277
|
+
prompt,
|
|
278
|
+
model=model,
|
|
279
|
+
n=n,
|
|
280
|
+
size=size,
|
|
281
|
+
quality=quality,
|
|
282
|
+
style=style,
|
|
283
|
+
response_format=response_format,
|
|
284
|
+
**kwargs,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# ── Internals ─────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
def _should_cache(self, temperature: float) -> bool:
|
|
290
|
+
if not self.only_cache_low_temperature:
|
|
291
|
+
return True
|
|
292
|
+
return temperature == 0.0
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _messages_payload(messages: MessageInput) -> Any:
|
|
296
|
+
"""Normalize *messages* into a stable JSON-friendly payload."""
|
|
297
|
+
return normalize_message_input(messages)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
__all__ = ["CachingLLMClient"]
|