modelrack 0.5.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.
modelrack/__about__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Single source of the package version, read by hatchling at build time."""
2
+
3
+ __version__ = "0.5.0"
modelrack/__init__.py ADDED
@@ -0,0 +1,169 @@
1
+ """modelrack — the suite's only model client.
2
+
3
+ Layer 3: a capability package over :mod:`baseaicore`'s vocabulary and ``httpx``. One
4
+ implementation of "talk to a local inference runtime", normalized into provider-neutral types, so
5
+ that FreeWeight, LoadCoach and IdeaPress never contain provider HTTP code, never parse provider
6
+ JSON, and never disagree about what a token count or a timing means
7
+ ([spec §1](../../docs/packages/modelrack/spec.md)).
8
+
9
+ What is exported below is the public API as of Phase 5
10
+ (``docs/packages/modelrack/development-plan.md``): the provider-neutral request and result
11
+ vocabulary, the streamed-event union with its cancellation token, the ``Provider`` protocol and the
12
+ types describing what a provider is, the full error hierarchy, and the three operational modules
13
+ Phase 5 added — the residency vocabulary (:mod:`modelrack.residency`), the one metadata cache this
14
+ package is allowed to have (:mod:`modelrack.cache`), and the optional observability hook
15
+ (:mod:`modelrack.events`).
16
+
17
+ The first adapter that ships is the **fake** one, deliberately
18
+ (ADR-0007 rule 6): ``FakeProvider`` is imported from
19
+ ``modelrack.testing``, not from here, so that the rest of the suite can be developed and tested
20
+ without a GPU, a model or a running runtime, while a test double stays one import away from the
21
+ production namespace rather than inside it. The first *real* adapter,
22
+ :class:`~modelrack.providers.ollama.OllamaProvider`, is imported from
23
+ ``modelrack.providers.ollama`` for a related but distinct reason: it is the one place in this
24
+ package that imports ``httpx``, and a process that only ever talks to the fake — most of this
25
+ suite's own test runs — has no reason to pay for that import. The second real adapter,
26
+ :class:`~modelrack.providers.openai_compatible.OpenAICompatibleProvider`, is imported from
27
+ ``modelrack.providers.openai_compatible`` for the same reason, and exists to prove the vocabulary
28
+ below is not secretly shaped around Ollama: nothing in this module changed to support it.
29
+
30
+ Anything not listed in ``__all__`` is private and may change without a version bump.
31
+
32
+ >>> from baseaicore import ModelIdentity, ProviderKind
33
+ >>> from modelrack import GenerationRequest, Message, Role
34
+ >>> request = GenerationRequest(
35
+ ... identity=ModelIdentity(ProviderKind.OLLAMA, "qwen3.5:9b-q8_0"),
36
+ ... messages=(Message(role=Role.USER, content="Explain KV caching."),),
37
+ ... )
38
+ >>> request.timeout_seconds is None # the adapter's default, never "no timeout"
39
+ True
40
+
41
+ Two invariants run through every type here. An unavailable measurement is ``UNSUPPORTED``, never
42
+ ``0`` (ADR-0016); and what a provider *reported*
43
+ about its own work is never merged with what this process *observed*, which is why
44
+ :class:`Timing` prefixes every field and offers no combined duration.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ from modelrack.__about__ import __version__
50
+ from modelrack.cache import (
51
+ DEFAULT_METADATA_TTL_SECONDS,
52
+ CacheStats,
53
+ MetadataCache,
54
+ MetadataSnapshot,
55
+ )
56
+ from modelrack.errors import (
57
+ CapabilityUnsupported,
58
+ ContextLimitExceeded,
59
+ GenerationCancelled,
60
+ ModelNotFound,
61
+ ProviderError,
62
+ ProviderProtocolError,
63
+ ProviderRejected,
64
+ ProviderTimeout,
65
+ ProviderUnavailable,
66
+ ProviderUnavailableReason,
67
+ )
68
+ from modelrack.events import (
69
+ EventCallback,
70
+ ProviderEvent,
71
+ ProviderEventKind,
72
+ )
73
+ from modelrack.provider import (
74
+ LoadResult,
75
+ Provider,
76
+ ProviderCapabilities,
77
+ ProviderHealth,
78
+ ProviderStatus,
79
+ ResidentModel,
80
+ refuse_capability,
81
+ require_capability,
82
+ )
83
+ from modelrack.residency import (
84
+ FORCE_UNLOAD,
85
+ RESIDENCY_QUERY,
86
+ ResidencySupport,
87
+ find_resident,
88
+ is_resident,
89
+ residency_support,
90
+ )
91
+ from modelrack.streaming import (
92
+ CancellationToken,
93
+ StreamCompleted,
94
+ StreamEvent,
95
+ StreamFailed,
96
+ ThinkingDelta,
97
+ TokenDelta,
98
+ ToolCallDelta,
99
+ )
100
+ from modelrack.types import (
101
+ FinishReason,
102
+ GenerationRequest,
103
+ GenerationResult,
104
+ GenerationUsage,
105
+ Message,
106
+ ResponseFormat,
107
+ ResponseFormatKind,
108
+ Role,
109
+ SamplingParameters,
110
+ Timing,
111
+ TokenUsage,
112
+ ToolCall,
113
+ ToolDefinition,
114
+ )
115
+
116
+ __all__ = [
117
+ "DEFAULT_METADATA_TTL_SECONDS",
118
+ "FORCE_UNLOAD",
119
+ "RESIDENCY_QUERY",
120
+ "CacheStats",
121
+ "CancellationToken",
122
+ "CapabilityUnsupported",
123
+ "ContextLimitExceeded",
124
+ "FinishReason",
125
+ "GenerationCancelled",
126
+ "GenerationRequest",
127
+ "GenerationResult",
128
+ "GenerationUsage",
129
+ "LoadResult",
130
+ "Message",
131
+ "ModelNotFound",
132
+ "Provider",
133
+ "ProviderCapabilities",
134
+ "ProviderError",
135
+ "ProviderHealth",
136
+ "ProviderProtocolError",
137
+ "ProviderRejected",
138
+ "ProviderStatus",
139
+ "ProviderTimeout",
140
+ "ProviderUnavailable",
141
+ "ProviderUnavailableReason",
142
+ "ResidentModel",
143
+ "ResponseFormat",
144
+ "ResponseFormatKind",
145
+ "Role",
146
+ "SamplingParameters",
147
+ "StreamCompleted",
148
+ "StreamEvent",
149
+ "StreamFailed",
150
+ "ThinkingDelta",
151
+ "Timing",
152
+ "TokenDelta",
153
+ "TokenUsage",
154
+ "ToolCall",
155
+ "ToolCallDelta",
156
+ "ToolDefinition",
157
+ "EventCallback",
158
+ "MetadataCache",
159
+ "MetadataSnapshot",
160
+ "ProviderEvent",
161
+ "ProviderEventKind",
162
+ "ResidencySupport",
163
+ "find_resident",
164
+ "is_resident",
165
+ "refuse_capability",
166
+ "require_capability",
167
+ "residency_support",
168
+ "__version__",
169
+ ]
modelrack/cache.py ADDED
@@ -0,0 +1,309 @@
1
+ """Domain module — the one cache this package is allowed to have.
2
+
3
+ Imports :mod:`baseaicore` and the standard library; performs no I/O.
4
+ [Spec §3](../../docs/packages/modelrack/spec.md) forbids caching in general and then carves out
5
+ exactly one exception: *no caching beyond a documented in-memory metadata cache with an explicit
6
+ TTL and a* ``clear()``. Spec §10 adds that it is "inspectable and clearable" and "never survives
7
+ the process". This module is that carve-out and nothing more.
8
+
9
+ **Metadata only — never a generation.** A model's descriptor is a fact about what the provider is
10
+ serving, and re-deriving it costs a ``/api/show`` round trip per model (spec §15 budgets a cold
11
+ 20-model discovery at seconds for exactly that reason). A *generation* is not a fact about
12
+ anything; two identical requests to the same model are two different runs, and a cache that
13
+ returned the first result for the second would fabricate a measurement FreeWeight would then
14
+ record as real. Nothing in this package puts a :class:`~modelrack.types.GenerationResult` in here,
15
+ and a test asserts it.
16
+
17
+ **Why a monotonic clock.** A TTL measured against the wall clock is extended or expired by an NTP
18
+ correction or a DST-adjacent system-time change — the entry would outlive its own expiry through
19
+ no fault of the caller's. :func:`baseaicore.monotonic_ns` cannot go backwards, so an entry expires
20
+ after the time that actually passed. The clock is injected for the same reason every clock in this
21
+ suite is: a TTL test that has to sleep for 300 seconds is a test nobody runs.
22
+
23
+ **Why the TTL is not the whole answer.** A tag such as ``qwen3.5:latest`` can be repointed at any
24
+ moment, so a cached descriptor's digest can be stale the instant after it is stored — the
25
+ development plan names this as Phase 5's likely failure mode. The TTL bounds how long that can go
26
+ unnoticed; the ``refresh=True`` argument on every adapter method that reads metadata is what lets
27
+ a caller who *knows* a model was re-pulled bypass it immediately. Both exist because neither alone
28
+ is enough.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import threading
34
+ from dataclasses import dataclass
35
+ from typing import TYPE_CHECKING, Final
36
+
37
+ from baseaicore import ValidationError, monotonic_ns
38
+
39
+ if TYPE_CHECKING:
40
+ from collections.abc import Callable, Mapping
41
+ from datetime import datetime
42
+ from typing import Any
43
+
44
+ __all__ = [
45
+ "DEFAULT_METADATA_TTL_SECONDS",
46
+ "CacheStats",
47
+ "MetadataCache",
48
+ "MetadataSnapshot",
49
+ ]
50
+
51
+ DEFAULT_METADATA_TTL_SECONDS: Final[float] = 300.0
52
+ """Spec §10's default: five minutes."""
53
+
54
+ _NANOS_PER_SECOND: Final[int] = 1_000_000_000
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class CacheStats:
59
+ """What a cache has done since it was created or last cleared.
60
+
61
+ Spec §10 requires the cache to be *inspectable*, and the development plan requires "cache-hit
62
+ reporting" — this is that report. Counters are cumulative and are reset by
63
+ :meth:`MetadataCache.clear`, so a caller measuring one discovery pass clears first and reads
64
+ after.
65
+
66
+ Attributes:
67
+ hits: Reads that found a live entry.
68
+ misses: Reads that found nothing — never stored, or already dropped.
69
+ expirations: Reads that found an entry whose TTL had passed. Counted **in addition to**
70
+ the miss they also produce: a cache whose misses are all expirations is a cache whose
71
+ TTL is too short, and one whose misses are never expirations is being asked for keys
72
+ it was never given. Those are opposite problems and a single counter cannot tell them
73
+ apart.
74
+ stores: Values written.
75
+ entries: How many live-or-expired entries are held right now. Not a rate — a size.
76
+ """
77
+
78
+ hits: int = 0
79
+ misses: int = 0
80
+ expirations: int = 0
81
+ stores: int = 0
82
+ entries: int = 0
83
+
84
+ @property
85
+ def lookups(self) -> int:
86
+ """Total reads, hits and misses together.
87
+
88
+ Returns:
89
+ ``hits + misses``. Offered because the hit *rate* is what a caller actually wants and
90
+ deriving it from two fields invites the off-by-one of forgetting expirations are
91
+ already counted inside ``misses``.
92
+ """
93
+ return self.hits + self.misses
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class MetadataSnapshot:
98
+ """One provider payload together with the instant it was actually read.
99
+
100
+ The pair exists because caching the payload alone would falsify
101
+ :attr:`~baseaicore.ModelDescriptor.observed_at`, whose documented meaning is *when this
102
+ snapshot was read from the provider*. An adapter that served a five-minute-old ``show`` body
103
+ and stamped it with the current clock would report a reading that never happened at that
104
+ instant — the same class of error as reporting an unmeasured value as ``0``
105
+ (ADR-0016), and one that would quietly
106
+ corrupt FreeWeight's freshness accounting.
107
+
108
+ Attributes:
109
+ observed_at: When the provider answered. Timezone-aware, UTC — it comes from the
110
+ adapter's injected clock, not from :func:`~baseaicore.monotonic_ns`, because it is a
111
+ point on the calendar a caller will store and compare, not a duration.
112
+ payload: The provider's own JSON body, unmodified. ``Any``-valued because it is provider
113
+ JSON: this is the same untouched shape that reaches
114
+ :attr:`~baseaicore.ModelDescriptor.raw`, and narrowing it here would mean parsing it
115
+ here, which is the adapter's job.
116
+ """
117
+
118
+ observed_at: datetime
119
+ payload: Mapping[str, Any]
120
+
121
+
122
+ class MetadataCache[ValueT]:
123
+ """An in-memory, TTL-bounded store for provider metadata, and nothing else.
124
+
125
+ Generic over what it holds so one implementation serves both a list of descriptors and a
126
+ single ``show`` payload without either being widened to ``Any`` at a boundary the coding
127
+ standards forbid one at.
128
+
129
+ Thread-safe. An adapter is a plain object a caller may share across threads — the suite's own
130
+ web layer dispatches synchronous provider work to a threadpool — and a dict mutated from two
131
+ threads mid-resize is the kind of failure that appears once a month in production and never in
132
+ a test. The lock is held only around the dictionary operations, never across a callback or an
133
+ HTTP call.
134
+
135
+ It is deliberately **not** a single-flight cache: two threads that miss the same key at the
136
+ same moment both fetch, and the second store wins. Holding the lock across the fetch would
137
+ serialize every caller behind one slow round trip and make an adapter's own timeout the whole
138
+ process's, which is a far worse failure than one duplicated ``/api/show``. What this cache
139
+ exists to remove is the *steady-state* cost of re-reading metadata, not a thundering herd.
140
+
141
+ Args:
142
+ ttl_seconds: How long an entry stays live. ``0`` disables the cache entirely: every read
143
+ misses, every write is dropped, and the counters still tell the truth about what was
144
+ asked for — the honest way to spell "no caching" without a second code path in every
145
+ caller.
146
+ clock: Where "now" comes from, as a monotonic nanosecond reading. Injected so a TTL test
147
+ can advance time instead of sleeping through it (coding standards §5).
148
+
149
+ Raises:
150
+ ValidationError: If ``ttl_seconds`` is negative. A negative lifetime has no meaning, and
151
+ silently treating it as ``0`` would hide a caller's unit mistake — seconds passed
152
+ where a negative sentinel was intended is a bug worth surfacing at construction.
153
+ """
154
+
155
+ __slots__ = (
156
+ "_clock",
157
+ "_entries",
158
+ "_expirations",
159
+ "_hits",
160
+ "_lock",
161
+ "_misses",
162
+ "_stores",
163
+ "_ttl_ns",
164
+ )
165
+
166
+ def __init__(
167
+ self,
168
+ *,
169
+ ttl_seconds: float = DEFAULT_METADATA_TTL_SECONDS,
170
+ clock: Callable[[], int] = monotonic_ns,
171
+ ) -> None:
172
+ """Create an empty cache with the given lifetime."""
173
+ if ttl_seconds < 0:
174
+ raise ValidationError(
175
+ f"MetadataCache ttl_seconds must not be negative; got {ttl_seconds}. Pass 0 to "
176
+ "disable caching.",
177
+ details={"field": "ttl_seconds", "value": ttl_seconds},
178
+ )
179
+ self._ttl_ns = int(ttl_seconds * _NANOS_PER_SECOND)
180
+ self._clock = clock
181
+ self._lock = threading.Lock()
182
+ self._entries: dict[str, tuple[int, ValueT]] = {}
183
+ self._hits = 0
184
+ self._misses = 0
185
+ self._expirations = 0
186
+ self._stores = 0
187
+
188
+ @property
189
+ def ttl_seconds(self) -> float:
190
+ """How long an entry stays live, in seconds.
191
+
192
+ Returns:
193
+ The lifetime this cache was constructed with. ``0.0`` when caching is disabled.
194
+ """
195
+ return self._ttl_ns / _NANOS_PER_SECOND
196
+
197
+ @property
198
+ def is_enabled(self) -> bool:
199
+ """Whether this cache stores anything at all.
200
+
201
+ Returns:
202
+ ``False`` when the TTL is ``0``, in which case every :meth:`get` misses and every
203
+ :meth:`put` is dropped.
204
+ """
205
+ return self._ttl_ns > 0
206
+
207
+ def get(self, key: str) -> ValueT | None:
208
+ """Return the live value stored under ``key``, or ``None``.
209
+
210
+ Args:
211
+ key: What the value was stored under.
212
+
213
+ Returns:
214
+ The value if it is present and its TTL has not passed; ``None`` otherwise. An expired
215
+ entry is dropped on the way out rather than left to accumulate, so a long-lived
216
+ adapter that asks for one model repeatedly does not grow a graveyard of the others.
217
+
218
+ Note:
219
+ ``None`` is the miss signal, which means this cache cannot hold ``None`` as a *value*.
220
+ That is deliberate rather than an oversight: every value it exists to hold is a
221
+ descriptor or a payload, and "the provider has no metadata for this model" is a
222
+ :class:`~modelrack.errors.ModelNotFound`, not a cacheable answer.
223
+ """
224
+ with self._lock:
225
+ entry = self._entries.get(key)
226
+ if entry is None:
227
+ self._misses += 1
228
+ return None
229
+ stored_ns, value = entry
230
+ if self._clock() - stored_ns >= self._ttl_ns:
231
+ del self._entries[key]
232
+ self._expirations += 1
233
+ self._misses += 1
234
+ return None
235
+ self._hits += 1
236
+ return value
237
+
238
+ def put(self, key: str, value: ValueT) -> None:
239
+ """Store ``value`` under ``key``, starting its TTL now.
240
+
241
+ Args:
242
+ key: What to store it under. An existing entry is replaced and its lifetime restarts —
243
+ a re-read of metadata is fresher than what it replaces, and keeping the older
244
+ entry's expiry would discard that freshness for no reason.
245
+ value: What to store. Dropped without comment when the cache is disabled.
246
+ """
247
+ if self._ttl_ns <= 0:
248
+ return
249
+ with self._lock:
250
+ self._entries[key] = (self._clock(), value)
251
+ self._stores += 1
252
+
253
+ def invalidate(self, key: str) -> bool:
254
+ """Drop one entry.
255
+
256
+ Args:
257
+ key: The entry to drop.
258
+
259
+ Returns:
260
+ ``True`` if an entry was there — live or expired — and has been removed. Used by an
261
+ adapter that has just learned one model's metadata changed and has no reason to
262
+ discard the other nineteen.
263
+ """
264
+ with self._lock:
265
+ return self._entries.pop(key, None) is not None
266
+
267
+ def clear(self) -> None:
268
+ """Drop every entry and reset the counters.
269
+
270
+ Spec §10's required escape hatch: a caller who has re-pulled a model, or who simply does
271
+ not trust what is held, gets a guaranteed-cold next read. Counters reset with the
272
+ contents, because a hit rate that spans a clear describes two different caches.
273
+ """
274
+ with self._lock:
275
+ self._entries.clear()
276
+ self._hits = 0
277
+ self._misses = 0
278
+ self._expirations = 0
279
+ self._stores = 0
280
+
281
+ def stats(self) -> CacheStats:
282
+ """Report what this cache has done.
283
+
284
+ Returns:
285
+ A snapshot. Taken under the lock so the counters are mutually consistent — a report
286
+ whose ``hits`` and ``entries`` came from either side of a concurrent write would
287
+ describe a state the cache was never in.
288
+ """
289
+ with self._lock:
290
+ return CacheStats(
291
+ hits=self._hits,
292
+ misses=self._misses,
293
+ expirations=self._expirations,
294
+ stores=self._stores,
295
+ entries=len(self._entries),
296
+ )
297
+
298
+ def __len__(self) -> int:
299
+ """Return how many entries are held, live or expired.
300
+
301
+ Counts expired-but-not-yet-read entries too: they occupy memory until something asks for
302
+ them, and a length that pretended otherwise would understate what the process is holding.
303
+ """
304
+ with self._lock:
305
+ return len(self._entries)
306
+
307
+ def __repr__(self) -> str:
308
+ """Return a representation naming the TTL and the size, for a debugger session."""
309
+ return f"MetadataCache(ttl_seconds={self.ttl_seconds:g}, entries={len(self)})"