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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- 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
|