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