modelspec-dev 0.1.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.
- api/__init__.py +0 -0
- api/class_fit.py +334 -0
- api/classes.py +557 -0
- api/ranking/__init__.py +12 -0
- api/ranking/engine.py +1943 -0
- cli/__init__.py +0 -0
- cli/modelspec/__init__.py +0 -0
- cli/modelspec/cli.py +1819 -0
- cli/modelspec/commands/__init__.py +0 -0
- cli/modelspec/decide_cmd.py +333 -0
- cli/modelspec/offline.py +623 -0
- cli/modelspec/snapshot.py +698 -0
- cli/modelspec/snapshot_build_cmd.py +49 -0
- cli/modelspec/verify_cmd.py +125 -0
- cli/modelspec/vocab_cmd.py +204 -0
- cli/modelspec/vocabulary_cache.py +54 -0
- decision/__init__.py +13 -0
- decision/capability.py +872 -0
- decision/computed.py +125 -0
- decision/contract.py +1575 -0
- decision/engine.py +238 -0
- decision/excluded.py +34 -0
- decision/explain.py +908 -0
- decision/filter.py +796 -0
- decision/model.py +438 -0
- decision/normalise.py +604 -0
- decision/optimise.py +320 -0
- decision/registry.py +717 -0
- decision/relax.py +132 -0
- decision/resolve.py +111 -0
- decision/schema.py +21 -0
- decision/snapshot.py +1483 -0
- decision/sources.py +544 -0
- decision/templates.py +134 -0
- decision/verify.py +1745 -0
- decision/vocabulary.py +433 -0
- modelspec_dev-0.1.0.dist-info/METADATA +101 -0
- modelspec_dev-0.1.0.dist-info/RECORD +63 -0
- modelspec_dev-0.1.0.dist-info/WHEEL +4 -0
- modelspec_dev-0.1.0.dist-info/entry_points.txt +2 -0
- modelspec_dev-0.1.0.dist-info/licenses/LICENSE +43 -0
- modelspec_dev-0.1.0.dist-info/licenses/LICENSE-DATA +428 -0
- pipeline/__init__.py +0 -0
- pipeline/class_export.py +172 -0
- pipeline/hardware.py +434 -0
- pipeline/hosts.py +247 -0
- pipeline/load.py +224 -0
- pipeline/ranking.py +551 -0
- registry/domains.yaml +130 -0
- registry/facets.yaml +888 -0
- registry/harnesses.yaml +79 -0
- registry/providers.yaml +354 -0
- registry/sources.yaml +3059 -0
- registry/templates.yaml +166 -0
- schema/__init__.py +0 -0
- schema/applicability.py +147 -0
- schema/benchmark.py +175 -0
- schema/benchmark_eligibility.py +304 -0
- schema/card.py +1463 -0
- schema/enrichment.py +162 -0
- schema/enums.py +327 -0
- schema/graph.py +406 -0
- schema/suppliers.py +72 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
"""The free path: a local snapshot of the published export, usable offline.
|
|
2
|
+
|
|
3
|
+
The CLI's graph commands need FalkorDB on localhost:6382. That is fine for
|
|
4
|
+
someone exploring the graph locally and useless for everyone else, including
|
|
5
|
+
dpf's ticket author, who needs an answer on a machine that has never run a
|
|
6
|
+
database.
|
|
7
|
+
|
|
8
|
+
This module downloads the versioned export from modelspec.dev, caches it, and
|
|
9
|
+
answers from the cache. No credential, no account, no network once fetched.
|
|
10
|
+
|
|
11
|
+
MODEL-71 adds one thing and changes nothing else: `fetch` can present a key to
|
|
12
|
+
an origin that keys its export, so a caller entitled to the current tree gets a
|
|
13
|
+
snapshot whose `fetched_at` is now rather than one built from a 90-day-delayed
|
|
14
|
+
public export. The default origin is unkeyed and the free path is untouched —
|
|
15
|
+
without a credential this module behaves exactly as it did. The credential is
|
|
16
|
+
carried in a `Credential`, which has no printable form of the secret, so a key
|
|
17
|
+
cannot reach a log line by being interpolated into one.
|
|
18
|
+
|
|
19
|
+
The pin identity is `build.commit` plus `build.export_schema_version`. The
|
|
20
|
+
latter is the published JSON tree, not the CLI `--json` envelope
|
|
21
|
+
(`offline.SCHEMA_VERSION`) and not `rankings.json`'s `schema_version`.
|
|
22
|
+
|
|
23
|
+
The continuity rule matters more than freshness: an answer from a snapshot three
|
|
24
|
+
weeks old, clearly labelled as three weeks old, is far more useful than an error.
|
|
25
|
+
Callers are told the age and decide for themselves.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import secrets
|
|
35
|
+
import shutil
|
|
36
|
+
import time
|
|
37
|
+
from collections.abc import Mapping
|
|
38
|
+
from dataclasses import dataclass, field, replace
|
|
39
|
+
from datetime import UTC, datetime
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
DEFAULT_ORIGIN = "https://modelspec.dev"
|
|
44
|
+
|
|
45
|
+
#: Where the credential for a keyed origin is read from. The environment is the
|
|
46
|
+
#: supported way to supply it: `docs/agent-commerce-assessment.md` §4 notes that
|
|
47
|
+
#: a credential on the command line lands in process listings, shell history and
|
|
48
|
+
#: logs, and an environment variable lands in fewer of those.
|
|
49
|
+
API_KEY_ENV = "MODELSPEC_API_KEY"
|
|
50
|
+
|
|
51
|
+
#: How a key identifies itself in a message a human or a log will see. The same
|
|
52
|
+
#: 12 hex characters of SHA-256 the origin calls `key_id` (MODEL-69,
|
|
53
|
+
#: `api/worker/src/access_keys.py`), so a support conversation can name the same
|
|
54
|
+
#: key from both ends without either end quoting the secret.
|
|
55
|
+
KEY_ID_LENGTH = 12
|
|
56
|
+
|
|
57
|
+
#: What replaces the secret if one ever reaches a string that is about to be
|
|
58
|
+
#: printed. Nothing should get that far; this is the backstop, not the plan.
|
|
59
|
+
REDACTED = "[redacted]"
|
|
60
|
+
|
|
61
|
+
#: Files that make a usable snapshot. Kept small on purpose — the whole point is
|
|
62
|
+
#: that this works on a laptop tethered to a phone.
|
|
63
|
+
PARTS = {
|
|
64
|
+
"index": "/api/index.json",
|
|
65
|
+
"candidates": "/api/rank/candidates.json",
|
|
66
|
+
"profiles": "/api/rank/profiles.json",
|
|
67
|
+
"hardware": "/api/graph/views/hardware.json",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#: Parts a snapshot may lack. An export older than MODEL-26 phase B has no
|
|
71
|
+
#: hosts.json; the snapshot still answers everything except `fit --host`.
|
|
72
|
+
OPTIONAL_PARTS = {
|
|
73
|
+
"hosts": "/api/hosts.json",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
DECISION_SNAPSHOT_ROUTE = "/api/decision/snapshot.json.gz"
|
|
77
|
+
DECISION_VOCABULARY_ROUTE = "/api/decision/vocabulary.json"
|
|
78
|
+
DECISION_DIRECTORY = "decision"
|
|
79
|
+
DECISION_SNAPSHOT_FILENAME = "snapshot.json.gz"
|
|
80
|
+
DECISION_VOCABULARY_FILENAME = "vocabulary.json"
|
|
81
|
+
DECISION_SNAPSHOT_ID = re.compile(r"snap_[0-9a-f]{16}")
|
|
82
|
+
|
|
83
|
+
#: Past this, the snapshot is still served but every answer says it is stale.
|
|
84
|
+
#: Model releases move weekly, so a month-old snapshot is a different world.
|
|
85
|
+
STALE_AFTER_DAYS = 30
|
|
86
|
+
|
|
87
|
+
#: Major.minor of `build.export_schema_version` this CLI will consume.
|
|
88
|
+
#: Must match `pipeline.export.EXPORT_SCHEMA_VERSION`. A different major is
|
|
89
|
+
#: refused so a breaking export cannot be ranked as if it were the old shape.
|
|
90
|
+
#: 2.0 since MODEL-77 reshaped the policy fields on the published cards.
|
|
91
|
+
EXPORT_SCHEMA_VERSION = "3.0"
|
|
92
|
+
|
|
93
|
+
#: What a snapshot with no `export_schema_version` at all actually is: an
|
|
94
|
+
#: export from before the field was added, which is the 1.x tree. It is named
|
|
95
|
+
#: rather than defaulted to the current version, because "the field is missing"
|
|
96
|
+
#: and "the field says whatever this CLI happens to be" stopped being the same
|
|
97
|
+
#: statement the moment the current version left 1.x — and assuming the latter
|
|
98
|
+
#: would read a pre-MODEL-77 card as if `commercial_use` were still a bool.
|
|
99
|
+
PRE_VERSIONED_EXPORT_SCHEMA_VERSION = "1.0"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cache_dir() -> Path:
|
|
103
|
+
"""Respects XDG, so a user can point it somewhere else or clear it."""
|
|
104
|
+
override = os.environ.get("MODELSPEC_CACHE")
|
|
105
|
+
if override:
|
|
106
|
+
return Path(override).expanduser()
|
|
107
|
+
base = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
|
|
108
|
+
return Path(base).expanduser() / "modelspec"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _decision_root(directory: Path | None = None) -> Path:
|
|
112
|
+
return (directory or cache_dir()) / DECISION_DIRECTORY
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _validate_decision_generation(generation: Path, *, require_named: bool = True) -> str:
|
|
116
|
+
from decision.snapshot import load_snapshot
|
|
117
|
+
|
|
118
|
+
snapshot_path = generation / DECISION_SNAPSHOT_FILENAME
|
|
119
|
+
vocabulary_path = generation / DECISION_VOCABULARY_FILENAME
|
|
120
|
+
decision = load_snapshot(snapshot_path, key=None, include_archive=True)
|
|
121
|
+
vocabulary = json.loads(vocabulary_path.read_text(encoding="utf-8"))
|
|
122
|
+
if not isinstance(vocabulary, dict):
|
|
123
|
+
raise ValueError("cached decision vocabulary is not a JSON object")
|
|
124
|
+
if vocabulary.get("snapshot") != decision.snapshot_id:
|
|
125
|
+
raise ValueError("cached decision vocabulary does not name the decision snapshot")
|
|
126
|
+
if require_named and generation.name != decision.snapshot_id:
|
|
127
|
+
raise ValueError("cached decision generation does not name the decision snapshot")
|
|
128
|
+
return decision.snapshot_id
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _current_decision_generation(
|
|
132
|
+
directory: Path | None = None,
|
|
133
|
+
) -> tuple[Path | None, str | None]:
|
|
134
|
+
root = _decision_root(directory)
|
|
135
|
+
try:
|
|
136
|
+
snapshot_id = (root / "current").read_text(encoding="utf-8").strip()
|
|
137
|
+
if DECISION_SNAPSHOT_ID.fullmatch(snapshot_id) is None:
|
|
138
|
+
raise ValueError("decision/current contains an invalid snapshot ID")
|
|
139
|
+
generation = root / snapshot_id
|
|
140
|
+
_validate_decision_generation(generation)
|
|
141
|
+
return generation, None
|
|
142
|
+
except FileNotFoundError:
|
|
143
|
+
return None, None
|
|
144
|
+
except (OSError, UnicodeError, ValueError, KeyError, json.JSONDecodeError) as exc:
|
|
145
|
+
return None, str(exc)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def decision_snapshot_path(directory: Path | None = None) -> Path:
|
|
149
|
+
generation, _error = _current_decision_generation(directory)
|
|
150
|
+
return ((generation / DECISION_SNAPSHOT_FILENAME) if generation else
|
|
151
|
+
(_decision_root(directory) / ".absent" / DECISION_SNAPSHOT_FILENAME))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def decision_vocabulary_path(directory: Path | None = None) -> Path:
|
|
155
|
+
generation, _error = _current_decision_generation(directory)
|
|
156
|
+
return ((generation / DECISION_VOCABULARY_FILENAME) if generation else
|
|
157
|
+
(_decision_root(directory) / ".absent" / DECISION_VOCABULARY_FILENAME))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class Snapshot:
|
|
162
|
+
path: Path
|
|
163
|
+
fetched_at: datetime
|
|
164
|
+
origin: str
|
|
165
|
+
build_commit: str
|
|
166
|
+
build_at: str
|
|
167
|
+
data: dict[str, Any]
|
|
168
|
+
export_schema_version: str = EXPORT_SCHEMA_VERSION
|
|
169
|
+
decision_fetch: dict[str, Any] | None = None
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def age_days(self) -> float:
|
|
173
|
+
return (datetime.now(UTC) - self.fetched_at).total_seconds() / 86400
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def is_stale(self) -> bool:
|
|
177
|
+
return self.age_days > STALE_AFTER_DAYS
|
|
178
|
+
|
|
179
|
+
def freshness(self) -> dict[str, Any]:
|
|
180
|
+
"""What every machine-readable answer carries, so nobody has to guess."""
|
|
181
|
+
return {
|
|
182
|
+
"fetched_at": self.fetched_at.isoformat(),
|
|
183
|
+
"age_days": round(self.age_days, 2),
|
|
184
|
+
"stale": self.is_stale,
|
|
185
|
+
"stale_after_days": STALE_AFTER_DAYS,
|
|
186
|
+
"origin": self.origin,
|
|
187
|
+
"build_commit": self.build_commit,
|
|
188
|
+
"built_at": self.build_at,
|
|
189
|
+
"export_schema_version": self.export_schema_version,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True, repr=False)
|
|
194
|
+
class Credential:
|
|
195
|
+
"""A key for a keyed origin, in a shape that cannot be printed by accident.
|
|
196
|
+
|
|
197
|
+
The secret is a field with no `repr`, and `__repr__`/`__str__` are replaced
|
|
198
|
+
by ones that emit the key id instead. That is the same reasoning the origin
|
|
199
|
+
applies to its key store (`docs/api-access.md`, "Keys are never written
|
|
200
|
+
down"): make "the secret is never logged" a property of the shape rather
|
|
201
|
+
than a rule every future caller has to remember.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
secret: str = field(repr=False)
|
|
205
|
+
#: `environment` or `flag`. Reported so a message can name where a rejected
|
|
206
|
+
#: key came from, and so the CLI can warn about the riskier of the two.
|
|
207
|
+
source: str = "environment"
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def key_id(self) -> str:
|
|
211
|
+
"""The short, loggable identifier. It identifies; it cannot authenticate."""
|
|
212
|
+
return hashlib.sha256(self.secret.encode("utf-8")).hexdigest()[:KEY_ID_LENGTH]
|
|
213
|
+
|
|
214
|
+
def headers(self) -> dict[str, str]:
|
|
215
|
+
"""How the key is presented. A header, never a query parameter.
|
|
216
|
+
|
|
217
|
+
URLs are written into access logs, proxy caches and referrer headers by
|
|
218
|
+
everything they pass through. `Authorization` is also the one header
|
|
219
|
+
httpx drops when a redirect crosses to another host, so a misconfigured
|
|
220
|
+
redirect cannot carry the key somewhere it was not meant to go.
|
|
221
|
+
"""
|
|
222
|
+
return {"Authorization": f"Bearer {self.secret}"}
|
|
223
|
+
|
|
224
|
+
def redact(self, text: str) -> str:
|
|
225
|
+
"""Backstop: strip the secret out of anything on its way to a stream."""
|
|
226
|
+
return text.replace(self.secret, REDACTED) if self.secret else text
|
|
227
|
+
|
|
228
|
+
def __repr__(self) -> str:
|
|
229
|
+
return f"Credential(source={self.source!r}, key_id={self.key_id!r})"
|
|
230
|
+
|
|
231
|
+
__str__ = __repr__
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def resolve_credential(flag: str | None = None,
|
|
235
|
+
environ: Mapping[str, str] | None = None) -> Credential | None:
|
|
236
|
+
"""The key to present, or `None` for the free, unkeyed path.
|
|
237
|
+
|
|
238
|
+
The environment is the preferred source and the flag is the override: a
|
|
239
|
+
caller who typed `--api-key` meant that key for this invocation. Preferring
|
|
240
|
+
the environment is a recommendation about how to supply a key, not a rule
|
|
241
|
+
that silently ignores the one in front of us.
|
|
242
|
+
"""
|
|
243
|
+
if flag:
|
|
244
|
+
return Credential(secret=flag, source="flag")
|
|
245
|
+
from_env = (environ if environ is not None else os.environ).get(API_KEY_ENV)
|
|
246
|
+
if from_env and from_env.strip():
|
|
247
|
+
return Credential(secret=from_env.strip(), source="environment")
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class SnapshotMissing(RuntimeError): # noqa: N818 - public compatibility name
|
|
252
|
+
"""No snapshot has been fetched yet."""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class SnapshotInvalid(RuntimeError): # noqa: N818 - follows SnapshotMissing naming
|
|
256
|
+
"""A snapshot exists but cannot be read or does not have the export shape."""
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class FetchError(RuntimeError):
|
|
260
|
+
"""A fetch that failed for a reason the caller can act on.
|
|
261
|
+
|
|
262
|
+
The four subclasses are the four things that go wrong against a keyed
|
|
263
|
+
origin. They exist so the CLI can give each one its own exit code and its
|
|
264
|
+
own sentence, instead of one `could not fetch` for everything.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class OriginUnreachableError(FetchError):
|
|
269
|
+
"""The origin did not answer: DNS, TLS, connection, timeout."""
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class KeyRequiredError(FetchError):
|
|
273
|
+
"""The origin wants a key and none was presented."""
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class KeyRejectedError(FetchError):
|
|
277
|
+
"""The origin knows what a key is and will not accept this one."""
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class RateLimitedError(FetchError):
|
|
281
|
+
"""The key is good and its window is spent."""
|
|
282
|
+
|
|
283
|
+
def __init__(self, message: str, retry_after_seconds: int | None = None) -> None:
|
|
284
|
+
super().__init__(message)
|
|
285
|
+
self.retry_after_seconds = retry_after_seconds
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _export_schema_major(version: str) -> int:
|
|
289
|
+
head = str(version).strip().split(".", 1)[0]
|
|
290
|
+
if not head.isdigit():
|
|
291
|
+
raise ValueError(
|
|
292
|
+
f"export_schema_version {version!r} is not a dotted major.minor version"
|
|
293
|
+
)
|
|
294
|
+
return int(head)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _declared_export_schema_version(data: dict[str, Any]) -> str:
|
|
298
|
+
"""Read the tree version from index.build.
|
|
299
|
+
|
|
300
|
+
A missing value is not "whatever this CLI is". It is the tree as it stood
|
|
301
|
+
before the field existed, which is 1.0 — so once this CLI moved past 1.x,
|
|
302
|
+
such a snapshot is refused like any other incompatible major instead of
|
|
303
|
+
being parsed as the current shape.
|
|
304
|
+
"""
|
|
305
|
+
index = data.get("index")
|
|
306
|
+
build = index.get("build") if isinstance(index, dict) else None
|
|
307
|
+
if not isinstance(build, dict):
|
|
308
|
+
return PRE_VERSIONED_EXPORT_SCHEMA_VERSION
|
|
309
|
+
raw = build.get("export_schema_version")
|
|
310
|
+
if raw is None:
|
|
311
|
+
return PRE_VERSIONED_EXPORT_SCHEMA_VERSION
|
|
312
|
+
return str(raw)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _require_compatible_export_schema(version: str) -> str:
|
|
316
|
+
try:
|
|
317
|
+
major = _export_schema_major(version)
|
|
318
|
+
except ValueError as exc:
|
|
319
|
+
raise SnapshotInvalid(str(exc)) from exc
|
|
320
|
+
expected = _export_schema_major(EXPORT_SCHEMA_VERSION)
|
|
321
|
+
if major != expected:
|
|
322
|
+
raise SnapshotInvalid(
|
|
323
|
+
f"export_schema_version {version} is incompatible with this CLI "
|
|
324
|
+
f"(expected {expected}.x). That field is the published JSON tree, "
|
|
325
|
+
"not the CLI --json envelope schema_version."
|
|
326
|
+
)
|
|
327
|
+
return version
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _snapshot_from_raw(path: Path, raw: Any) -> Snapshot:
|
|
331
|
+
if not isinstance(raw, dict):
|
|
332
|
+
raise ValueError("top-level JSON value must be an object")
|
|
333
|
+
meta = raw["meta"]
|
|
334
|
+
data = raw["data"]
|
|
335
|
+
if not isinstance(meta, dict) or not isinstance(data, dict):
|
|
336
|
+
raise ValueError("meta and data must be objects")
|
|
337
|
+
for key in ("fetched_at", "origin", "build_commit", "built_at"):
|
|
338
|
+
if key not in meta:
|
|
339
|
+
raise ValueError(f"meta is missing {key!r}")
|
|
340
|
+
for key in ("index", "candidates", "profiles", "hardware"):
|
|
341
|
+
if key not in data or not isinstance(data[key], dict):
|
|
342
|
+
raise ValueError(f"data is missing object {key!r}")
|
|
343
|
+
if not isinstance(data["candidates"].get("candidates"), list):
|
|
344
|
+
raise ValueError("data.candidates.candidates must be a list")
|
|
345
|
+
if not isinstance(data["profiles"].get("profiles"), dict):
|
|
346
|
+
raise ValueError("data.profiles.profiles must be an object")
|
|
347
|
+
if not isinstance(data["hardware"].get("nodes"), list):
|
|
348
|
+
raise ValueError("data.hardware.nodes must be a list")
|
|
349
|
+
fetched_at = datetime.fromisoformat(str(meta["fetched_at"]))
|
|
350
|
+
if fetched_at.tzinfo is None:
|
|
351
|
+
raise ValueError("meta.fetched_at must include a timezone")
|
|
352
|
+
version = _require_compatible_export_schema(_declared_export_schema_version(data))
|
|
353
|
+
return Snapshot(
|
|
354
|
+
path=path,
|
|
355
|
+
fetched_at=fetched_at,
|
|
356
|
+
origin=str(meta["origin"]),
|
|
357
|
+
build_commit=str(meta["build_commit"]),
|
|
358
|
+
build_at=str(meta["built_at"]),
|
|
359
|
+
data=data,
|
|
360
|
+
export_schema_version=version,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
#: Statuses the origin uses to refuse a call it understood (MODEL-69,
|
|
365
|
+
#: `docs/api-access.md`). Anything else stays on the pre-existing path:
|
|
366
|
+
#: `raise_for_status`, wrapped by the CLI as a plain runtime error.
|
|
367
|
+
HTTP_UNAUTHORIZED = 401
|
|
368
|
+
HTTP_FORBIDDEN = 403
|
|
369
|
+
HTTP_TOO_MANY_REQUESTS = 429
|
|
370
|
+
|
|
371
|
+
#: `error.code` values the origin sends. Read to tell "you sent no key" from
|
|
372
|
+
#: "that key is not one of ours", which are different things for the caller.
|
|
373
|
+
MISSING_KEY_CODE = "missing_api_key"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _origin_error(response: Any) -> tuple[str, str]:
|
|
377
|
+
"""The origin's own `(code, message)`, or empty strings if it sent neither.
|
|
378
|
+
|
|
379
|
+
The origin's refusals already say the useful thing — where to get a key,
|
|
380
|
+
when a window resets — so they are relayed rather than paraphrased. A body
|
|
381
|
+
that is not the documented shape is not trusted to be one.
|
|
382
|
+
"""
|
|
383
|
+
try:
|
|
384
|
+
body = response.json()
|
|
385
|
+
except Exception: # noqa: BLE001 - a refusal is not required to be JSON
|
|
386
|
+
return "", ""
|
|
387
|
+
error = body.get("error") if isinstance(body, dict) else None
|
|
388
|
+
if not isinstance(error, dict):
|
|
389
|
+
return "", ""
|
|
390
|
+
code = error.get("code")
|
|
391
|
+
message = error.get("message")
|
|
392
|
+
return (str(code) if code else "", str(message) if message else "")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _retry_after(response: Any) -> int | None:
|
|
396
|
+
header = str(response.headers.get("retry-after") or "").strip()
|
|
397
|
+
if header.isdigit():
|
|
398
|
+
return int(header)
|
|
399
|
+
try:
|
|
400
|
+
body = response.json()
|
|
401
|
+
except Exception: # noqa: BLE001 - as above
|
|
402
|
+
return None
|
|
403
|
+
error = body.get("error") if isinstance(body, dict) else None
|
|
404
|
+
seconds = error.get("retry_after_seconds") if isinstance(error, dict) else None
|
|
405
|
+
return int(seconds) if isinstance(seconds, int) else None
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _check_refusal(response: Any, origin: str, presented: Credential | None) -> None:
|
|
409
|
+
"""Turn the origin's refusal into the typed error that matches it.
|
|
410
|
+
|
|
411
|
+
Nothing here interpolates the key. `key_id` is the short fingerprint, which
|
|
412
|
+
identifies the key in the origin's logs without being usable against it.
|
|
413
|
+
"""
|
|
414
|
+
status = response.status_code
|
|
415
|
+
if status not in (HTTP_UNAUTHORIZED, HTTP_FORBIDDEN, HTTP_TOO_MANY_REQUESTS):
|
|
416
|
+
return
|
|
417
|
+
code, said = _origin_error(response)
|
|
418
|
+
tail = f" The origin said: {said}" if said else ""
|
|
419
|
+
if status == HTTP_TOO_MANY_REQUESTS:
|
|
420
|
+
seconds = _retry_after(response)
|
|
421
|
+
wait = f" Retry after {seconds}s." if seconds is not None else ""
|
|
422
|
+
raise RateLimitedError(
|
|
423
|
+
f"{origin} rate-limited this key"
|
|
424
|
+
f"{f' (key {presented.key_id})' if presented else ''}."
|
|
425
|
+
f"{wait}{tail}",
|
|
426
|
+
retry_after_seconds=seconds,
|
|
427
|
+
)
|
|
428
|
+
if presented is None or code == MISSING_KEY_CODE:
|
|
429
|
+
raise KeyRequiredError(
|
|
430
|
+
f"{origin} requires an API key and none was presented. Set "
|
|
431
|
+
f"{API_KEY_ENV} in the environment, or pass --api-key (which is "
|
|
432
|
+
f"visible in shell history and in `ps`)." + tail
|
|
433
|
+
)
|
|
434
|
+
raise KeyRejectedError(
|
|
435
|
+
f"{origin} rejected the API key supplied by the {presented.source} "
|
|
436
|
+
f"(key {presented.key_id}). The key value is not shown and was not "
|
|
437
|
+
f"logged.{tail}"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def fetch(origin: str = DEFAULT_ORIGIN, target: Path | None = None,
|
|
442
|
+
credential: Credential | None = None) -> Snapshot:
|
|
443
|
+
"""Download the export. The only command that needs the network.
|
|
444
|
+
|
|
445
|
+
With no `credential` this is the call it has always been. With one, the key
|
|
446
|
+
rides on an `Authorization` header — never in the URL, never in the cached
|
|
447
|
+
snapshot — and the origin's refusals come back as typed errors.
|
|
448
|
+
"""
|
|
449
|
+
import httpx
|
|
450
|
+
|
|
451
|
+
directory = target or cache_dir()
|
|
452
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
453
|
+
payload: dict[str, Any] = {}
|
|
454
|
+
headers = credential.headers() if credential is not None else {}
|
|
455
|
+
|
|
456
|
+
def get(route: str) -> Any:
|
|
457
|
+
try:
|
|
458
|
+
response = client.get(origin + route)
|
|
459
|
+
except httpx.HTTPError as exc:
|
|
460
|
+
raise OriginUnreachableError(
|
|
461
|
+
f"could not reach {origin}{route}: {type(exc).__name__}"
|
|
462
|
+
) from None
|
|
463
|
+
_check_refusal(response, origin, credential)
|
|
464
|
+
response.raise_for_status()
|
|
465
|
+
return response
|
|
466
|
+
|
|
467
|
+
with httpx.Client(timeout=60.0, follow_redirects=True, headers=headers) as client:
|
|
468
|
+
for name, route in PARTS.items():
|
|
469
|
+
payload[name] = get(route).json()
|
|
470
|
+
for name, route in OPTIONAL_PARTS.items():
|
|
471
|
+
try:
|
|
472
|
+
payload[name] = get(route).json()
|
|
473
|
+
except (KeyRequiredError, KeyRejectedError, RateLimitedError):
|
|
474
|
+
# A credential problem is a credential problem on any route, and
|
|
475
|
+
# saying so beats a snapshot that silently lost `fit --host`.
|
|
476
|
+
# An unreachable optional route is still skipped, as before.
|
|
477
|
+
raise
|
|
478
|
+
except Exception: # noqa: BLE001 - optional; `fit --host` reports its absence
|
|
479
|
+
continue
|
|
480
|
+
build = (payload.get("index") or {}).get("build") or {}
|
|
481
|
+
meta = {
|
|
482
|
+
"fetched_at": datetime.now(UTC).isoformat(),
|
|
483
|
+
"origin": origin,
|
|
484
|
+
"build_commit": build.get("commit", "unknown"),
|
|
485
|
+
"built_at": build.get("built_at", "unknown"),
|
|
486
|
+
}
|
|
487
|
+
raw = {"meta": meta, "data": payload}
|
|
488
|
+
final = directory / "snapshot.json"
|
|
489
|
+
# Validate before replacing the cache, so a bad fetch cannot clobber a
|
|
490
|
+
# snapshot that still answers.
|
|
491
|
+
try:
|
|
492
|
+
parsed = _snapshot_from_raw(final, raw)
|
|
493
|
+
except SnapshotInvalid:
|
|
494
|
+
raise
|
|
495
|
+
except (TypeError, ValueError, KeyError) as exc:
|
|
496
|
+
raise SnapshotInvalid(f"fetched export is unreadable or invalid: {exc}") from exc
|
|
497
|
+
raw["meta"]["export_schema_version"] = parsed.export_schema_version
|
|
498
|
+
writes = ((directory / f".snapshot.{os.getpid()}.tmp", final,
|
|
499
|
+
json.dumps(raw).encode("utf-8")),)
|
|
500
|
+
try:
|
|
501
|
+
for tmp, _destination, data in writes:
|
|
502
|
+
tmp.write_bytes(data)
|
|
503
|
+
for tmp, destination, _data in writes:
|
|
504
|
+
tmp.replace(destination)
|
|
505
|
+
finally:
|
|
506
|
+
for tmp, _destination, _data in writes:
|
|
507
|
+
tmp.unlink(missing_ok=True)
|
|
508
|
+
fetched = load(directory)
|
|
509
|
+
decision_fetch = _fetch_decision_files(origin, directory, credential)
|
|
510
|
+
return replace(fetched, decision_fetch=decision_fetch)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _fetch_decision_files(origin: str, directory: Path,
|
|
514
|
+
credential: Credential | None) -> dict[str, Any]:
|
|
515
|
+
"""Try to refresh the public decision pair without affecting rank fetch."""
|
|
516
|
+
import httpx
|
|
517
|
+
|
|
518
|
+
from decision.snapshot import SnapshotIntegrityError, load_snapshot_bytes
|
|
519
|
+
|
|
520
|
+
class DecisionRouteUnavailable(RuntimeError): # noqa: N818 - describes route state
|
|
521
|
+
pass
|
|
522
|
+
|
|
523
|
+
def download(candidate_origin: str, *, send_credential: bool) -> tuple[bytes, Any]:
|
|
524
|
+
candidate_headers = credential.headers() if credential and send_credential else {}
|
|
525
|
+
with httpx.Client(timeout=60.0, follow_redirects=True,
|
|
526
|
+
headers=candidate_headers) as client:
|
|
527
|
+
responses = []
|
|
528
|
+
for route in (DECISION_SNAPSHOT_ROUTE, DECISION_VOCABULARY_ROUTE):
|
|
529
|
+
try:
|
|
530
|
+
response = client.get(candidate_origin + route)
|
|
531
|
+
except httpx.HTTPError as exc:
|
|
532
|
+
raise DecisionRouteUnavailable(
|
|
533
|
+
f"could not reach {candidate_origin}{route}: {type(exc).__name__}"
|
|
534
|
+
) from None
|
|
535
|
+
if response.status_code in {401, 403, 404}:
|
|
536
|
+
raise DecisionRouteUnavailable(
|
|
537
|
+
f"{candidate_origin}{route} returned HTTP {response.status_code}"
|
|
538
|
+
)
|
|
539
|
+
try:
|
|
540
|
+
response.raise_for_status()
|
|
541
|
+
except Exception as exc: # noqa: BLE001 - optional download is reported
|
|
542
|
+
raise RuntimeError(
|
|
543
|
+
f"{candidate_origin}{route} returned HTTP {response.status_code}"
|
|
544
|
+
) from exc
|
|
545
|
+
responses.append(response)
|
|
546
|
+
return responses[0].content, responses[1].json()
|
|
547
|
+
|
|
548
|
+
source = origin
|
|
549
|
+
try:
|
|
550
|
+
try:
|
|
551
|
+
decision_data, vocabulary_raw = download(origin, send_credential=True)
|
|
552
|
+
except DecisionRouteUnavailable:
|
|
553
|
+
if origin.rstrip("/") == DEFAULT_ORIGIN:
|
|
554
|
+
raise
|
|
555
|
+
source = DEFAULT_ORIGIN
|
|
556
|
+
decision_data, vocabulary_raw = download(source, send_credential=False)
|
|
557
|
+
|
|
558
|
+
# A public client cannot verify the HMAC without the publishing secret.
|
|
559
|
+
# It still verifies the content hash and snapshot ID.
|
|
560
|
+
decision = load_snapshot_bytes(
|
|
561
|
+
decision_data, key=None, include_archive=True,
|
|
562
|
+
source=source + DECISION_SNAPSHOT_ROUTE,
|
|
563
|
+
)
|
|
564
|
+
if not isinstance(vocabulary_raw, dict):
|
|
565
|
+
raise SnapshotInvalid("fetched decision vocabulary is not a JSON object")
|
|
566
|
+
if vocabulary_raw.get("snapshot") != decision.snapshot_id:
|
|
567
|
+
raise SnapshotInvalid(
|
|
568
|
+
"fetched decision vocabulary snapshot does not match the decision snapshot "
|
|
569
|
+
f"({vocabulary_raw.get('snapshot')!r} != {decision.snapshot_id!r})"
|
|
570
|
+
)
|
|
571
|
+
except SnapshotIntegrityError as exc:
|
|
572
|
+
return {"available": False,
|
|
573
|
+
"error": f"fetched decision snapshot failed integrity check: {exc}"}
|
|
574
|
+
except Exception as exc: # noqa: BLE001 - decision data is optional for rank fetch
|
|
575
|
+
return {"available": False, "error": str(exc)}
|
|
576
|
+
|
|
577
|
+
root = _decision_root(directory)
|
|
578
|
+
generation = root / decision.snapshot_id
|
|
579
|
+
temporary = root / f".tmp-{os.getpid()}-{secrets.token_hex(6)}"
|
|
580
|
+
current_tmp = root / f".current-{os.getpid()}.tmp"
|
|
581
|
+
try:
|
|
582
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
583
|
+
temporary.mkdir()
|
|
584
|
+
(temporary / DECISION_SNAPSHOT_FILENAME).write_bytes(decision_data)
|
|
585
|
+
(temporary / DECISION_VOCABULARY_FILENAME).write_text(
|
|
586
|
+
json.dumps(vocabulary_raw), encoding="utf-8"
|
|
587
|
+
)
|
|
588
|
+
_validate_decision_generation(temporary, require_named=False)
|
|
589
|
+
if generation.exists():
|
|
590
|
+
try:
|
|
591
|
+
_validate_decision_generation(generation)
|
|
592
|
+
except (OSError, ValueError, KeyError, json.JSONDecodeError):
|
|
593
|
+
shutil.rmtree(generation)
|
|
594
|
+
os.replace(temporary, generation)
|
|
595
|
+
else:
|
|
596
|
+
shutil.rmtree(temporary)
|
|
597
|
+
else:
|
|
598
|
+
os.replace(temporary, generation)
|
|
599
|
+
current_tmp.write_text(decision.snapshot_id + "\n", encoding="utf-8")
|
|
600
|
+
os.replace(current_tmp, root / "current")
|
|
601
|
+
except Exception as exc: # noqa: BLE001 - decision data cannot fail rank fetch
|
|
602
|
+
return {"available": False, "error": f"could not cache decision generation: {exc}"}
|
|
603
|
+
finally:
|
|
604
|
+
try:
|
|
605
|
+
current_tmp.unlink(missing_ok=True)
|
|
606
|
+
except OSError:
|
|
607
|
+
pass
|
|
608
|
+
shutil.rmtree(temporary, ignore_errors=True)
|
|
609
|
+
|
|
610
|
+
_prune_decision_generations(root, decision.snapshot_id)
|
|
611
|
+
return {"available": True, "origin": source, "snapshot_id": decision.snapshot_id}
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _prune_decision_generations(root: Path, current_id: str) -> None:
|
|
615
|
+
"""Keep the active and previous generations; cleanup must not fail fetch."""
|
|
616
|
+
try:
|
|
617
|
+
generations = [
|
|
618
|
+
path for path in root.iterdir()
|
|
619
|
+
if path.is_dir() and not path.name.startswith(".tmp-")
|
|
620
|
+
]
|
|
621
|
+
generations.sort(key=lambda path: path.stat().st_mtime, reverse=True)
|
|
622
|
+
previous = [path.name for path in generations if path.name != current_id][:1]
|
|
623
|
+
keep = {current_id, *previous}
|
|
624
|
+
for path in generations:
|
|
625
|
+
if path.name not in keep:
|
|
626
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
627
|
+
for path in root.glob(".tmp-*"):
|
|
628
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
629
|
+
except OSError:
|
|
630
|
+
pass
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def load(directory: Path | None = None) -> Snapshot:
|
|
634
|
+
"""Read the cached snapshot. Never touches the network."""
|
|
635
|
+
path = (directory or cache_dir()) / "snapshot.json"
|
|
636
|
+
if not path.exists():
|
|
637
|
+
raise SnapshotMissing(
|
|
638
|
+
f"no snapshot at {path}. Run `modelspec snapshot fetch` once; "
|
|
639
|
+
"everything after that works offline."
|
|
640
|
+
)
|
|
641
|
+
try:
|
|
642
|
+
if not path.is_file():
|
|
643
|
+
raise ValueError("snapshot.json is not a regular file")
|
|
644
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
645
|
+
return _snapshot_from_raw(path, raw)
|
|
646
|
+
except SnapshotInvalid:
|
|
647
|
+
raise
|
|
648
|
+
except (OSError, UnicodeError, TypeError, ValueError, KeyError, json.JSONDecodeError) as exc:
|
|
649
|
+
raise SnapshotInvalid(f"snapshot at {path} is unreadable or invalid: {exc}") from exc
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def status(directory: Path | None = None) -> dict[str, Any]:
|
|
653
|
+
directory = directory or cache_dir()
|
|
654
|
+
generation, generation_error = _current_decision_generation(directory)
|
|
655
|
+
decision_path = ((generation / DECISION_SNAPSHOT_FILENAME) if generation else
|
|
656
|
+
(_decision_root(directory) / "current"))
|
|
657
|
+
if generation is not None:
|
|
658
|
+
from decision.snapshot import load_snapshot
|
|
659
|
+
|
|
660
|
+
try:
|
|
661
|
+
decision = load_snapshot(decision_path, key=None, include_archive=True)
|
|
662
|
+
decision_status: dict[str, Any] = {
|
|
663
|
+
"present": True,
|
|
664
|
+
"valid": True,
|
|
665
|
+
"path": str(decision_path),
|
|
666
|
+
"snapshot_id": decision.snapshot_id,
|
|
667
|
+
"as_of": decision.as_of.isoformat() if decision.as_of else None,
|
|
668
|
+
"age_days": round(age_of(decision_path), 2),
|
|
669
|
+
"signature_verified": decision.signature_verified,
|
|
670
|
+
}
|
|
671
|
+
except (OSError, ValueError) as exc:
|
|
672
|
+
decision_status = {
|
|
673
|
+
"present": True,
|
|
674
|
+
"valid": False,
|
|
675
|
+
"path": str(decision_path),
|
|
676
|
+
"error": f"cached decision snapshot is invalid: {exc}",
|
|
677
|
+
}
|
|
678
|
+
elif generation_error is not None:
|
|
679
|
+
decision_status = {
|
|
680
|
+
"present": True,
|
|
681
|
+
"valid": False,
|
|
682
|
+
"path": str(decision_path),
|
|
683
|
+
"error": f"cached decision snapshot is invalid: {generation_error}",
|
|
684
|
+
}
|
|
685
|
+
else:
|
|
686
|
+
decision_status = {"present": False, "path": str(decision_path)}
|
|
687
|
+
try:
|
|
688
|
+
snap = load(directory)
|
|
689
|
+
except SnapshotMissing as exc:
|
|
690
|
+
return {"present": False, "message": str(exc),
|
|
691
|
+
"decision_snapshot": decision_status}
|
|
692
|
+
size = snap.path.stat().st_size
|
|
693
|
+
return {"present": True, "size_bytes": size, "path": str(snap.path),
|
|
694
|
+
"decision_snapshot": decision_status, **snap.freshness()}
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def age_of(path: Path) -> float:
|
|
698
|
+
return (time.time() - path.stat().st_mtime) / 86400
|