graphite-code 0.3.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 (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,444 @@
1
+ """Cached, bounded Ollama inventory and exact model capability profiles."""
2
+ from __future__ import annotations
3
+
4
+ import http.client
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from datetime import date, timedelta
9
+ from enum import StrEnum
10
+ from types import MappingProxyType
11
+ from typing import Any, Final, Mapping
12
+
13
+ from .contracts import Effort, ModelProfile, RiskTier
14
+ from .effort import EFFORT_PAYLOADS
15
+ from .storage import RepositoryStore
16
+
17
+ REGISTRY_SCHEMA_VERSION: Final = "1"
18
+ MAX_INVENTORY_BYTES: Final = 2 * 1024 * 1024
19
+ MAX_INVENTORY_MODELS: Final = 128
20
+ MAX_HEADER_BYTES: Final = 16 * 1024
21
+ MAX_HEADERS: Final = 32
22
+ DEFAULT_REGISTRY_TTL_SECONDS: Final = 3_600
23
+ OLLAMA_HOSTS: Final = frozenset({"127.0.0.1", "::1"})
24
+ OLLAMA_PORT: Final = 11_434
25
+
26
+ _MODEL_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,127}(?::[a-z0-9][a-z0-9._-]{0,63})?$")
27
+ _DIGEST = re.compile(r"^[0-9a-f]{64}$")
28
+ _CAPABILITY = re.compile(r"^[a-z][a-z0-9_-]{0,31}$")
29
+
30
+
31
+ class RegistryError(RuntimeError):
32
+ """A stable, path-free model registry failure."""
33
+
34
+ def __init__(self, code: str) -> None:
35
+ self.code = code
36
+ super().__init__(code)
37
+
38
+
39
+ class UsageClass(StrEnum):
40
+ MEDIUM = "medium"
41
+ HIGH = "high"
42
+
43
+
44
+ class ModelRole(StrEnum):
45
+ CODING_PRIMARY = "coding_primary"
46
+ CODING = "coding"
47
+ AGENTIC = "agentic"
48
+ REASONING = "reasoning"
49
+ REVIEW = "review"
50
+ LONG_CONTEXT = "long_context"
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class RegistryProfile:
55
+ profile: ModelProfile
56
+ effort_payloads: Mapping[Effort, Mapping[str, Any]]
57
+ evidence_url: str
58
+ evidence_accessed: str
59
+ roles: tuple[ModelRole, ...]
60
+ usage_class: UsageClass
61
+ retirement_date: str | None = None
62
+
63
+ def __post_init__(self) -> None:
64
+ try:
65
+ normalized_roles = tuple(ModelRole(role) for role in self.roles)
66
+ except (TypeError, ValueError) as exc:
67
+ raise ValueError("roles_invalid") from exc
68
+ if not normalized_roles:
69
+ raise ValueError("roles_empty")
70
+ if len(set(normalized_roles)) != len(normalized_roles):
71
+ raise ValueError("roles_duplicate")
72
+ try:
73
+ normalized_usage = UsageClass(self.usage_class)
74
+ except (TypeError, ValueError) as exc:
75
+ raise ValueError("usage_class_invalid") from exc
76
+ accessed = _exact_iso_date(self.evidence_accessed, "evidence_accessed_invalid")
77
+ if self.retirement_date is not None:
78
+ retirement = _exact_iso_date(self.retirement_date, "retirement_date_invalid")
79
+ if retirement <= accessed:
80
+ raise ValueError("retirement_date_invalid")
81
+ object.__setattr__(self, "roles", normalized_roles)
82
+ object.__setattr__(self, "usage_class", normalized_usage)
83
+
84
+
85
+ def _exact_iso_date(value: object, code: str) -> date:
86
+ if not isinstance(value, str):
87
+ raise ValueError(code)
88
+ try:
89
+ parsed = date.fromisoformat(value)
90
+ except ValueError as exc:
91
+ raise ValueError(code) from exc
92
+ if parsed.isoformat() != value:
93
+ raise ValueError(code)
94
+ return parsed
95
+
96
+
97
+ def lifecycle_is_eligible(
98
+ retirement_date: str | None,
99
+ current_date: str,
100
+ *,
101
+ minimum_runway_days: int = 30,
102
+ ) -> bool:
103
+ """Return whether a profile has strictly more than the required runway."""
104
+ if (
105
+ isinstance(minimum_runway_days, bool)
106
+ or not isinstance(minimum_runway_days, int)
107
+ or minimum_runway_days < 0
108
+ or minimum_runway_days > 365
109
+ ):
110
+ raise ValueError("lifecycle_runway_invalid")
111
+ current = _exact_iso_date(current_date, "lifecycle_date_invalid")
112
+ if retirement_date is None:
113
+ return True
114
+ retirement = _exact_iso_date(retirement_date, "lifecycle_date_invalid")
115
+ return retirement > current + timedelta(days=minimum_runway_days)
116
+
117
+
118
+ BUNDLED_PROFILES: Final[Mapping[str, RegistryProfile]] = MappingProxyType({
119
+ "kimi-k2.7-code:cloud": RegistryProfile(
120
+ profile=ModelProfile(
121
+ model_id="kimi-k2.7-code:cloud",
122
+ profile_version="2026-07-14.2",
123
+ capabilities=("code", "completion", "tools", "thinking", "vision"),
124
+ context_window_tokens=262_144,
125
+ supported_efforts=(Effort.DEFAULT,),
126
+ provisional=True,
127
+ ),
128
+ effort_payloads=EFFORT_PAYLOADS["kimi-k2.7-code:cloud"],
129
+ evidence_url="https://ollama.com/library/kimi-k2.7-code",
130
+ evidence_accessed="2026-07-14",
131
+ roles=(ModelRole.CODING_PRIMARY, ModelRole.CODING),
132
+ usage_class=UsageClass.HIGH,
133
+ ),
134
+ "minimax-m2.7:cloud": RegistryProfile(
135
+ profile=ModelProfile(
136
+ model_id="minimax-m2.7:cloud",
137
+ profile_version="2026-07-14.2",
138
+ capabilities=("code", "completion", "tools", "thinking"),
139
+ context_window_tokens=204_800,
140
+ supported_efforts=(Effort.DEFAULT,),
141
+ provisional=True,
142
+ ),
143
+ effort_payloads=EFFORT_PAYLOADS["minimax-m2.7:cloud"],
144
+ evidence_url="https://ollama.com/library/minimax-m2.7:cloud",
145
+ evidence_accessed="2026-07-14",
146
+ roles=(ModelRole.CODING, ModelRole.AGENTIC),
147
+ usage_class=UsageClass.MEDIUM,
148
+ ),
149
+ "nemotron-3-super:cloud": RegistryProfile(
150
+ profile=ModelProfile(
151
+ model_id="nemotron-3-super:cloud",
152
+ profile_version="2026-07-14.2",
153
+ capabilities=("completion", "reasoning", "tools", "thinking"),
154
+ context_window_tokens=262_144,
155
+ supported_efforts=(Effort.DEFAULT,),
156
+ provisional=True,
157
+ ),
158
+ effort_payloads=EFFORT_PAYLOADS["nemotron-3-super:cloud"],
159
+ evidence_url="https://ollama.com/library/nemotron-3-super:cloud",
160
+ evidence_accessed="2026-07-14",
161
+ roles=(ModelRole.REASONING, ModelRole.REVIEW),
162
+ usage_class=UsageClass.MEDIUM,
163
+ ),
164
+ "minimax-m3:cloud": RegistryProfile(
165
+ profile=ModelProfile(
166
+ model_id="minimax-m3:cloud",
167
+ profile_version="2026-07-14.2",
168
+ capabilities=(
169
+ "architecture",
170
+ "completion",
171
+ "reasoning",
172
+ "tools",
173
+ "thinking",
174
+ "vision",
175
+ ),
176
+ context_window_tokens=524_288,
177
+ supported_efforts=(Effort.DEFAULT,),
178
+ provisional=True,
179
+ ),
180
+ effort_payloads=EFFORT_PAYLOADS["minimax-m3:cloud"],
181
+ evidence_url="https://ollama.com/library/minimax-m3:cloud",
182
+ evidence_accessed="2026-07-14",
183
+ roles=(ModelRole.LONG_CONTEXT, ModelRole.AGENTIC),
184
+ usage_class=UsageClass.HIGH,
185
+ ),
186
+ })
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class InventoryModel:
191
+ model_id: str
192
+ digest: str
193
+ context_window_tokens: int
194
+ capabilities: tuple[str, ...]
195
+
196
+ def to_dict(self) -> dict[str, Any]:
197
+ return {
198
+ "model_id": self.model_id,
199
+ "digest": self.digest,
200
+ "context_window_tokens": self.context_window_tokens,
201
+ "capabilities": list(self.capabilities),
202
+ }
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class RegistrySnapshot:
207
+ schema_version: str
208
+ refreshed_at: int
209
+ expires_at: int
210
+ models: tuple[InventoryModel, ...]
211
+
212
+ def to_dict(self) -> dict[str, Any]:
213
+ return {
214
+ "schema_version": self.schema_version,
215
+ "refreshed_at": self.refreshed_at,
216
+ "expires_at": self.expires_at,
217
+ "models": [model.to_dict() for model in self.models],
218
+ }
219
+
220
+ def to_storage_dict(self) -> dict[str, Any]:
221
+ return self.to_dict()
222
+
223
+
224
+ def _bounded_integer(value: object, code: str, minimum: int, maximum: int) -> int:
225
+ if isinstance(value, bool) or not isinstance(value, int) or value < minimum or value > maximum:
226
+ raise RegistryError(code)
227
+ return value
228
+
229
+
230
+ def _model_from_payload(value: object) -> InventoryModel:
231
+ if not isinstance(value, dict):
232
+ raise RegistryError("registry_model_invalid")
233
+ name = value.get("name")
234
+ model = value.get("model")
235
+ if (
236
+ not isinstance(name, str)
237
+ or not _MODEL_ID.fullmatch(name)
238
+ or model != name
239
+ ):
240
+ raise RegistryError("registry_model_invalid")
241
+ digest = value.get("digest")
242
+ if isinstance(digest, str) and digest.startswith("sha256:"):
243
+ digest = digest[7:]
244
+ if not isinstance(digest, str) or not _DIGEST.fullmatch(digest):
245
+ raise RegistryError("registry_digest_invalid")
246
+ details = value.get("details", {})
247
+ if not isinstance(details, dict):
248
+ raise RegistryError("registry_details_invalid")
249
+ context = details.get("context_length", 0)
250
+ context = _bounded_integer(context, "registry_context_invalid", 0, 4_194_304)
251
+ raw_capabilities = value.get("capabilities", [])
252
+ if not isinstance(raw_capabilities, list) or len(raw_capabilities) > 16:
253
+ raise RegistryError("registry_capabilities_invalid")
254
+ capabilities: list[str] = []
255
+ for capability in raw_capabilities:
256
+ if not isinstance(capability, str) or not _CAPABILITY.fullmatch(capability):
257
+ raise RegistryError("registry_capabilities_invalid")
258
+ capabilities.append(capability)
259
+ return InventoryModel(name, digest, context, tuple(sorted(set(capabilities))))
260
+
261
+
262
+ def find_inventory_model(snapshot: RegistrySnapshot, model_id: str) -> InventoryModel:
263
+ """Find an exact observed tag without applying routing-profile policy."""
264
+ if not isinstance(snapshot, RegistrySnapshot) or not isinstance(model_id, str) or _MODEL_ID.fullmatch(model_id) is None:
265
+ raise RegistryError("registry_model_invalid")
266
+ for model in snapshot.models:
267
+ if model.model_id == model_id:
268
+ return model
269
+ raise RegistryError("model_unavailable")
270
+
271
+
272
+ def parse_inventory(
273
+ payload: object,
274
+ *,
275
+ refreshed_at: int,
276
+ ttl_seconds: int = DEFAULT_REGISTRY_TTL_SECONDS,
277
+ ) -> RegistrySnapshot:
278
+ """Convert an Ollama tags payload to a sanitized bounded snapshot."""
279
+ refreshed = _bounded_integer(refreshed_at, "registry_time_invalid", 0, 10**12)
280
+ ttl = _bounded_integer(ttl_seconds, "registry_ttl_invalid", 1, 86_400)
281
+ if not isinstance(payload, dict) or not isinstance(payload.get("models"), list):
282
+ raise RegistryError("registry_payload_invalid")
283
+ raw_models = payload["models"]
284
+ if len(raw_models) > MAX_INVENTORY_MODELS:
285
+ raise RegistryError("registry_model_limit")
286
+ models = tuple(sorted((_model_from_payload(item) for item in raw_models), key=lambda item: item.model_id))
287
+ if len({model.model_id for model in models}) != len(models):
288
+ raise RegistryError("registry_model_duplicate")
289
+ return RegistrySnapshot(REGISTRY_SCHEMA_VERSION, refreshed, refreshed + ttl, models)
290
+
291
+
292
+ def _snapshot_from_storage(payload: object) -> RegistrySnapshot:
293
+ if not isinstance(payload, dict) or set(payload) != {
294
+ "schema_version",
295
+ "refreshed_at",
296
+ "expires_at",
297
+ "models",
298
+ }:
299
+ raise RegistryError("registry_snapshot_invalid")
300
+ if payload["schema_version"] != REGISTRY_SCHEMA_VERSION:
301
+ raise RegistryError("registry_snapshot_version")
302
+ refreshed = _bounded_integer(payload["refreshed_at"], "registry_time_invalid", 0, 10**12)
303
+ expires = _bounded_integer(payload["expires_at"], "registry_time_invalid", 0, 10**12)
304
+ if expires <= refreshed:
305
+ raise RegistryError("registry_time_invalid")
306
+ raw_models = payload["models"]
307
+ if not isinstance(raw_models, list) or len(raw_models) > MAX_INVENTORY_MODELS:
308
+ raise RegistryError("registry_model_limit")
309
+ models: list[InventoryModel] = []
310
+ for value in raw_models:
311
+ if not isinstance(value, dict) or set(value) != {
312
+ "model_id",
313
+ "digest",
314
+ "context_window_tokens",
315
+ "capabilities",
316
+ }:
317
+ raise RegistryError("registry_model_invalid")
318
+ models.append(
319
+ _model_from_payload(
320
+ {
321
+ "name": value["model_id"],
322
+ "model": value["model_id"],
323
+ "digest": value["digest"],
324
+ "details": {"context_length": value["context_window_tokens"]},
325
+ "capabilities": value["capabilities"],
326
+ }
327
+ )
328
+ )
329
+ ordered = tuple(sorted(models, key=lambda item: item.model_id))
330
+ if len({model.model_id for model in ordered}) != len(ordered):
331
+ raise RegistryError("registry_model_duplicate")
332
+ return RegistrySnapshot(REGISTRY_SCHEMA_VERSION, refreshed, expires, ordered)
333
+
334
+
335
+ def load_cached_registry(store: RepositoryStore, *, now: int) -> RegistrySnapshot:
336
+ """Load an offline snapshot without opening a socket or starting a process."""
337
+ payload = store.load_registry_snapshot()
338
+ if payload is None:
339
+ raise RegistryError("registry_snapshot_missing")
340
+ snapshot = _snapshot_from_storage(payload)
341
+ current = _bounded_integer(now, "registry_time_invalid", 0, 10**12)
342
+ if current > snapshot.expires_at:
343
+ raise RegistryError("registry_snapshot_expired")
344
+ return snapshot
345
+
346
+
347
+ def _read_response_bounded(response: http.client.HTTPResponse) -> bytes:
348
+ headers = list(response.headers.items())
349
+ if len(headers) > MAX_HEADERS:
350
+ raise RegistryError("registry_headers_limit")
351
+ header_bytes = sum(len(str(name)) + len(str(value)) + 4 for name, value in headers)
352
+ if header_bytes > MAX_HEADER_BYTES:
353
+ raise RegistryError("registry_headers_limit")
354
+ content_length = response.headers.get("Content-Length")
355
+ if content_length is not None:
356
+ try:
357
+ declared = int(content_length)
358
+ except ValueError as exc:
359
+ raise RegistryError("registry_headers_invalid") from exc
360
+ if declared < 0 or declared > MAX_INVENTORY_BYTES:
361
+ raise RegistryError("registry_body_limit")
362
+ chunks: list[bytes] = []
363
+ remaining = MAX_INVENTORY_BYTES + 1
364
+ while remaining > 0:
365
+ chunk = response.read(min(64 * 1024, remaining))
366
+ if not chunk:
367
+ break
368
+ chunks.append(chunk)
369
+ remaining -= len(chunk)
370
+ data = b"".join(chunks)
371
+ if len(data) > MAX_INVENTORY_BYTES:
372
+ raise RegistryError("registry_body_limit")
373
+ return data
374
+
375
+
376
+ def refresh_model_inventory(
377
+ store: RepositoryStore,
378
+ *,
379
+ now: int,
380
+ ttl_seconds: int = DEFAULT_REGISTRY_TTL_SECONDS,
381
+ host: str = "127.0.0.1",
382
+ port: int = OLLAMA_PORT,
383
+ timeout_seconds: float = 5.0,
384
+ ) -> RegistrySnapshot:
385
+ """Explicitly refresh the local Ollama inventory over canonical loopback."""
386
+ if host not in OLLAMA_HOSTS or port != OLLAMA_PORT:
387
+ raise RegistryError("registry_endpoint_invalid")
388
+ if isinstance(timeout_seconds, bool) or not isinstance(timeout_seconds, (int, float)) or not 0.1 <= timeout_seconds <= 30:
389
+ raise RegistryError("registry_timeout_invalid")
390
+ connection: http.client.HTTPConnection | None = None
391
+ try:
392
+ connection = http.client.HTTPConnection(host, port, timeout=float(timeout_seconds))
393
+ connection.request(
394
+ "GET",
395
+ "/api/tags",
396
+ headers={"Accept": "application/json", "Connection": "close"},
397
+ )
398
+ response = connection.getresponse()
399
+ if response.status != 200:
400
+ raise RegistryError("registry_http_status")
401
+ raw = _read_response_bounded(response)
402
+ except RegistryError:
403
+ raise
404
+ except (OSError, TimeoutError, http.client.HTTPException) as exc:
405
+ raise RegistryError("registry_unavailable") from exc
406
+ finally:
407
+ if connection is not None:
408
+ connection.close()
409
+ try:
410
+ payload = json.loads(raw.decode("utf-8"))
411
+ except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as exc:
412
+ raise RegistryError("registry_payload_invalid") from exc
413
+ snapshot = parse_inventory(payload, refreshed_at=now, ttl_seconds=ttl_seconds)
414
+ store.save_registry_snapshot(snapshot.to_storage_dict())
415
+ return snapshot
416
+
417
+
418
+ def require_model_available(
419
+ snapshot: RegistrySnapshot,
420
+ model_id: str,
421
+ *,
422
+ expected_digest: str | None = None,
423
+ ) -> InventoryModel:
424
+ if model_id not in BUNDLED_PROFILES:
425
+ raise RegistryError("model_profile_missing")
426
+ for model in snapshot.models:
427
+ if model.model_id == model_id:
428
+ if expected_digest is not None and model.digest != expected_digest:
429
+ raise RegistryError("model_identity_changed")
430
+ return model
431
+ raise RegistryError("model_unavailable")
432
+
433
+
434
+ def profile_is_eligible(model_id: str, risk: RiskTier | str) -> bool:
435
+ entry = BUNDLED_PROFILES.get(model_id)
436
+ if entry is None:
437
+ return False
438
+ try:
439
+ normalized_risk = RiskTier(risk)
440
+ except (TypeError, ValueError):
441
+ return False
442
+ if entry.profile.provisional and normalized_risk is RiskTier.HIGH:
443
+ return False
444
+ return True