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,543 @@
|
|
|
1
|
+
"""Bounded non-inference process and HTTP probe boundary."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import http.client
|
|
6
|
+
import ipaddress
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import queue
|
|
10
|
+
import socket
|
|
11
|
+
import ssl
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Final, Protocol
|
|
19
|
+
|
|
20
|
+
from graphite.probe_process import run_bounded_process
|
|
21
|
+
|
|
22
|
+
from .contracts import ProviderId
|
|
23
|
+
from .lifecycle import LifecycleProviderId
|
|
24
|
+
from .process_runner import (
|
|
25
|
+
CliProcessFailureDiagnostics,
|
|
26
|
+
CliProcessError,
|
|
27
|
+
CliProcessResult,
|
|
28
|
+
ProcessRunner,
|
|
29
|
+
run_cli_process,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
MAX_PROBE_RESPONSE_BYTES: Final = 64 * 1024
|
|
33
|
+
MAX_PROBE_REQUEST_BYTES: Final = 64 * 1024
|
|
34
|
+
# The OpenRouter models catalog is one bounded JSON document that legitimately
|
|
35
|
+
# exceeds the metadata cap (measured 512 KiB / 338 entries on 2026-07-20).
|
|
36
|
+
MAX_CATALOG_RESPONSE_BYTES: Final = 4_194_304
|
|
37
|
+
MAX_INFERENCE_REQUEST_BYTES: Final = 1_048_576
|
|
38
|
+
MAX_INFERENCE_RESPONSE_BYTES: Final = 4_194_304
|
|
39
|
+
MAX_INFERENCE_TIMEOUT_SECONDS: Final = 600.0
|
|
40
|
+
MAX_PROBE_HEADERS: Final = 64
|
|
41
|
+
MAX_PROBE_HEADER_BYTES: Final = 16 * 1024
|
|
42
|
+
MAX_DNS_WORKERS: Final = 4
|
|
43
|
+
_OPENROUTER_HOSTS: Final = frozenset({"openrouter.ai"})
|
|
44
|
+
_ZAI_HOSTS: Final = frozenset({"api.z.ai"})
|
|
45
|
+
_DNS_SLOTS = threading.BoundedSemaphore(MAX_DNS_WORKERS)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ProviderProbeError(RuntimeError):
|
|
49
|
+
"""Stable probe failure containing no endpoint, credential, or response data."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
code: str,
|
|
54
|
+
*,
|
|
55
|
+
diagnostics: CliProcessFailureDiagnostics | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.code = code
|
|
58
|
+
self.diagnostics = (
|
|
59
|
+
diagnostics
|
|
60
|
+
if isinstance(diagnostics, CliProcessFailureDiagnostics)
|
|
61
|
+
else None
|
|
62
|
+
)
|
|
63
|
+
super().__init__(code)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ProbeEndpointPurpose(StrEnum):
|
|
67
|
+
OLLAMA_VERSION = "ollama_version"
|
|
68
|
+
OLLAMA_TAGS = "ollama_tags"
|
|
69
|
+
OLLAMA_SHOW = "ollama_show"
|
|
70
|
+
OPENROUTER_MODELS = "openrouter_models"
|
|
71
|
+
OPENROUTER_AUTH_KEY = "openrouter_auth_key"
|
|
72
|
+
OPENROUTER_CHAT_COMPLETIONS = "openrouter_chat_completions"
|
|
73
|
+
ZAI_CHAT_COMPLETIONS = "zai_chat_completions"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_PURPOSE_POLICY: Final = {
|
|
77
|
+
ProbeEndpointPurpose.OLLAMA_VERSION: (
|
|
78
|
+
LifecycleProviderId.OLLAMA,
|
|
79
|
+
"GET",
|
|
80
|
+
"/api/version",
|
|
81
|
+
),
|
|
82
|
+
ProbeEndpointPurpose.OLLAMA_TAGS: (
|
|
83
|
+
LifecycleProviderId.OLLAMA,
|
|
84
|
+
"GET",
|
|
85
|
+
"/api/tags",
|
|
86
|
+
),
|
|
87
|
+
ProbeEndpointPurpose.OLLAMA_SHOW: (
|
|
88
|
+
LifecycleProviderId.OLLAMA,
|
|
89
|
+
"POST",
|
|
90
|
+
"/api/show",
|
|
91
|
+
),
|
|
92
|
+
ProbeEndpointPurpose.OPENROUTER_MODELS: (
|
|
93
|
+
LifecycleProviderId.OPENROUTER,
|
|
94
|
+
"GET",
|
|
95
|
+
"/api/v1/models",
|
|
96
|
+
),
|
|
97
|
+
ProbeEndpointPurpose.OPENROUTER_AUTH_KEY: (
|
|
98
|
+
LifecycleProviderId.OPENROUTER,
|
|
99
|
+
"GET",
|
|
100
|
+
"/api/v1/auth/key",
|
|
101
|
+
),
|
|
102
|
+
ProbeEndpointPurpose.OPENROUTER_CHAT_COMPLETIONS: (
|
|
103
|
+
LifecycleProviderId.OPENROUTER,
|
|
104
|
+
"POST",
|
|
105
|
+
"/api/v1/chat/completions",
|
|
106
|
+
),
|
|
107
|
+
ProbeEndpointPurpose.ZAI_CHAT_COMPLETIONS: (
|
|
108
|
+
LifecycleProviderId.ZAI,
|
|
109
|
+
"POST",
|
|
110
|
+
"/api/paas/v4/chat/completions",
|
|
111
|
+
),
|
|
112
|
+
}
|
|
113
|
+
_BODY_PURPOSES: Final = frozenset(
|
|
114
|
+
{
|
|
115
|
+
ProbeEndpointPurpose.OLLAMA_SHOW,
|
|
116
|
+
ProbeEndpointPurpose.OPENROUTER_CHAT_COMPLETIONS,
|
|
117
|
+
ProbeEndpointPurpose.ZAI_CHAT_COMPLETIONS,
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
_INFERENCE_PURPOSES: Final = frozenset(
|
|
121
|
+
{
|
|
122
|
+
ProbeEndpointPurpose.OPENROUTER_CHAT_COMPLETIONS,
|
|
123
|
+
ProbeEndpointPurpose.ZAI_CHAT_COMPLETIONS,
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
_CATALOG_PURPOSES: Final = frozenset({ProbeEndpointPurpose.OPENROUTER_MODELS})
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class HttpProbeEndpoint:
|
|
131
|
+
provider: LifecycleProviderId
|
|
132
|
+
scheme: str
|
|
133
|
+
host: str
|
|
134
|
+
port: int
|
|
135
|
+
purpose: ProbeEndpointPurpose
|
|
136
|
+
allowed_ollama_ports: frozenset[int] = frozenset({11434})
|
|
137
|
+
|
|
138
|
+
def __post_init__(self) -> None:
|
|
139
|
+
try:
|
|
140
|
+
provider = LifecycleProviderId(self.provider)
|
|
141
|
+
purpose = ProbeEndpointPurpose(self.purpose)
|
|
142
|
+
except (TypeError, ValueError):
|
|
143
|
+
raise ProviderProbeError("probe_endpoint_invalid") from None
|
|
144
|
+
if (
|
|
145
|
+
not isinstance(self.host, str)
|
|
146
|
+
or not self.host
|
|
147
|
+
or self.host != self.host.casefold()
|
|
148
|
+
or isinstance(self.port, bool)
|
|
149
|
+
or not isinstance(self.port, int)
|
|
150
|
+
or not 1 <= self.port <= 65535
|
|
151
|
+
or _PURPOSE_POLICY[purpose][0] is not provider
|
|
152
|
+
):
|
|
153
|
+
raise ProviderProbeError("probe_endpoint_invalid")
|
|
154
|
+
if provider is LifecycleProviderId.OLLAMA:
|
|
155
|
+
try:
|
|
156
|
+
address = ipaddress.ip_address(self.host)
|
|
157
|
+
except ValueError:
|
|
158
|
+
raise ProviderProbeError("probe_endpoint_invalid") from None
|
|
159
|
+
if (
|
|
160
|
+
self.scheme != "http"
|
|
161
|
+
or not address.is_loopback
|
|
162
|
+
or not self.allowed_ollama_ports
|
|
163
|
+
or self.port not in self.allowed_ollama_ports
|
|
164
|
+
or any(
|
|
165
|
+
isinstance(value, bool)
|
|
166
|
+
or not isinstance(value, int)
|
|
167
|
+
or not 1 <= value <= 65535
|
|
168
|
+
for value in self.allowed_ollama_ports
|
|
169
|
+
)
|
|
170
|
+
):
|
|
171
|
+
raise ProviderProbeError("probe_endpoint_invalid")
|
|
172
|
+
elif provider is LifecycleProviderId.ZAI:
|
|
173
|
+
if (
|
|
174
|
+
self.scheme != "https"
|
|
175
|
+
or self.host not in _ZAI_HOSTS
|
|
176
|
+
or self.port != 443
|
|
177
|
+
or self.allowed_ollama_ports != frozenset({11434})
|
|
178
|
+
):
|
|
179
|
+
raise ProviderProbeError("probe_endpoint_invalid")
|
|
180
|
+
elif (
|
|
181
|
+
provider is not LifecycleProviderId.OPENROUTER
|
|
182
|
+
or self.scheme != "https"
|
|
183
|
+
or self.host not in _OPENROUTER_HOSTS
|
|
184
|
+
or self.port != 443
|
|
185
|
+
or self.allowed_ollama_ports != frozenset({11434})
|
|
186
|
+
):
|
|
187
|
+
raise ProviderProbeError("probe_endpoint_invalid")
|
|
188
|
+
object.__setattr__(self, "provider", provider)
|
|
189
|
+
object.__setattr__(self, "purpose", purpose)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True, slots=True)
|
|
193
|
+
class HttpProbeResult:
|
|
194
|
+
status_code: int
|
|
195
|
+
body: bytes = field(repr=False)
|
|
196
|
+
body_sha256: str
|
|
197
|
+
duration_seconds: float
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class _Response(Protocol):
|
|
201
|
+
status: int
|
|
202
|
+
headers: object
|
|
203
|
+
|
|
204
|
+
def read(self, amount: int) -> bytes: ...
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class _Connection(Protocol):
|
|
208
|
+
sock: object
|
|
209
|
+
|
|
210
|
+
def connect(self) -> None: ...
|
|
211
|
+
def request(self, method: str, path: str, **kwargs: object) -> None: ...
|
|
212
|
+
def getresponse(self) -> _Response: ...
|
|
213
|
+
def close(self) -> None: ...
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
Resolver = Callable[..., Sequence[tuple[object, ...]]]
|
|
217
|
+
ConnectionFactory = Callable[[HttpProbeEndpoint, str, float], _Connection]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class _PinnedHTTPConnection(http.client.HTTPConnection):
|
|
221
|
+
def __init__(self, host: str, port: int, address: str, timeout: float) -> None:
|
|
222
|
+
super().__init__(host, port, timeout=timeout)
|
|
223
|
+
self._pinned_address = address
|
|
224
|
+
|
|
225
|
+
def connect(self) -> None:
|
|
226
|
+
self.sock = socket.create_connection(
|
|
227
|
+
(self._pinned_address, self.port),
|
|
228
|
+
self.timeout,
|
|
229
|
+
self.source_address,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class _PinnedHTTPSConnection(http.client.HTTPSConnection):
|
|
234
|
+
def __init__(self, host: str, port: int, address: str, timeout: float) -> None:
|
|
235
|
+
super().__init__(host, port, timeout=timeout, context=ssl.create_default_context())
|
|
236
|
+
self._pinned_address = address
|
|
237
|
+
|
|
238
|
+
def connect(self) -> None:
|
|
239
|
+
raw = socket.create_connection(
|
|
240
|
+
(self._pinned_address, self.port),
|
|
241
|
+
self.timeout,
|
|
242
|
+
self.source_address,
|
|
243
|
+
)
|
|
244
|
+
try:
|
|
245
|
+
self.sock = self._context.wrap_socket(raw, server_hostname=self.host)
|
|
246
|
+
except Exception:
|
|
247
|
+
raw.close()
|
|
248
|
+
raise
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _default_connection(
|
|
252
|
+
endpoint: HttpProbeEndpoint,
|
|
253
|
+
address: str,
|
|
254
|
+
timeout: float,
|
|
255
|
+
) -> _Connection:
|
|
256
|
+
if endpoint.scheme == "https":
|
|
257
|
+
return _PinnedHTTPSConnection(endpoint.host, endpoint.port, address, timeout)
|
|
258
|
+
return _PinnedHTTPConnection(endpoint.host, endpoint.port, address, timeout)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _remaining(deadline: float, clock: Callable[[], float]) -> float:
|
|
262
|
+
remaining = deadline - clock()
|
|
263
|
+
if remaining <= 0:
|
|
264
|
+
raise ProviderProbeError("probe_timeout")
|
|
265
|
+
return remaining
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _resolve(
|
|
269
|
+
endpoint: HttpProbeEndpoint,
|
|
270
|
+
*,
|
|
271
|
+
deadline: float,
|
|
272
|
+
resolver: Resolver,
|
|
273
|
+
clock: Callable[[], float],
|
|
274
|
+
) -> tuple[str, ...]:
|
|
275
|
+
if not _DNS_SLOTS.acquire(blocking=False):
|
|
276
|
+
raise ProviderProbeError("probe_dns_busy")
|
|
277
|
+
results: queue.Queue[object] = queue.Queue(maxsize=1)
|
|
278
|
+
|
|
279
|
+
def worker() -> None:
|
|
280
|
+
try:
|
|
281
|
+
value: object = resolver(
|
|
282
|
+
endpoint.host,
|
|
283
|
+
endpoint.port,
|
|
284
|
+
type=socket.SOCK_STREAM,
|
|
285
|
+
proto=socket.IPPROTO_TCP,
|
|
286
|
+
)
|
|
287
|
+
except Exception:
|
|
288
|
+
value = ProviderProbeError("probe_unavailable")
|
|
289
|
+
try:
|
|
290
|
+
results.put_nowait(value)
|
|
291
|
+
except queue.Full:
|
|
292
|
+
pass
|
|
293
|
+
finally:
|
|
294
|
+
_DNS_SLOTS.release()
|
|
295
|
+
|
|
296
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
297
|
+
try:
|
|
298
|
+
resolved = results.get(timeout=_remaining(deadline, clock))
|
|
299
|
+
except queue.Empty:
|
|
300
|
+
raise ProviderProbeError("probe_timeout") from None
|
|
301
|
+
if isinstance(resolved, ProviderProbeError):
|
|
302
|
+
raise resolved
|
|
303
|
+
if not isinstance(resolved, (tuple, list)) or not resolved:
|
|
304
|
+
raise ProviderProbeError("probe_address_invalid")
|
|
305
|
+
addresses: list[str] = []
|
|
306
|
+
for record in resolved:
|
|
307
|
+
if not isinstance(record, tuple) or len(record) < 5:
|
|
308
|
+
raise ProviderProbeError("probe_address_invalid")
|
|
309
|
+
socket_address = record[4]
|
|
310
|
+
if not isinstance(socket_address, tuple) or not socket_address:
|
|
311
|
+
raise ProviderProbeError("probe_address_invalid")
|
|
312
|
+
raw_address = socket_address[0]
|
|
313
|
+
try:
|
|
314
|
+
address = ipaddress.ip_address(raw_address)
|
|
315
|
+
except (TypeError, ValueError):
|
|
316
|
+
raise ProviderProbeError("probe_address_invalid") from None
|
|
317
|
+
if endpoint.provider is LifecycleProviderId.OLLAMA:
|
|
318
|
+
permitted = address.is_loopback
|
|
319
|
+
else:
|
|
320
|
+
permitted = address.is_global
|
|
321
|
+
if not permitted:
|
|
322
|
+
raise ProviderProbeError("probe_address_invalid")
|
|
323
|
+
normalized = str(address)
|
|
324
|
+
if normalized not in addresses:
|
|
325
|
+
addresses.append(normalized)
|
|
326
|
+
return tuple(addresses)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _peer_address(connection: _Connection) -> str:
|
|
330
|
+
try:
|
|
331
|
+
peer = connection.sock.getpeername()
|
|
332
|
+
address = ipaddress.ip_address(peer[0])
|
|
333
|
+
except (AttributeError, OSError, TypeError, ValueError, IndexError):
|
|
334
|
+
raise ProviderProbeError("probe_peer_invalid") from None
|
|
335
|
+
return str(address)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _read_response(
|
|
339
|
+
response: _Response,
|
|
340
|
+
maximum: int,
|
|
341
|
+
*,
|
|
342
|
+
transport_socket: object,
|
|
343
|
+
deadline: float,
|
|
344
|
+
clock: Callable[[], float],
|
|
345
|
+
) -> bytes:
|
|
346
|
+
try:
|
|
347
|
+
headers = list(response.headers.items())
|
|
348
|
+
except (AttributeError, TypeError, ValueError):
|
|
349
|
+
raise ProviderProbeError("probe_headers_invalid") from None
|
|
350
|
+
if len(headers) > MAX_PROBE_HEADERS:
|
|
351
|
+
raise ProviderProbeError("probe_response_limit")
|
|
352
|
+
header_size = sum(len(str(name)) + len(str(value)) + 4 for name, value in headers)
|
|
353
|
+
if header_size > MAX_PROBE_HEADER_BYTES:
|
|
354
|
+
raise ProviderProbeError("probe_response_limit")
|
|
355
|
+
content_type = response.headers.get("Content-Type")
|
|
356
|
+
if (
|
|
357
|
+
not isinstance(content_type, str)
|
|
358
|
+
or content_type.split(";", 1)[0].strip().casefold() != "application/json"
|
|
359
|
+
):
|
|
360
|
+
raise ProviderProbeError("probe_content_type_invalid")
|
|
361
|
+
declared_value = response.headers.get("Content-Length")
|
|
362
|
+
declared: int | None = None
|
|
363
|
+
if declared_value is not None:
|
|
364
|
+
try:
|
|
365
|
+
declared = int(declared_value, 10)
|
|
366
|
+
except (TypeError, ValueError):
|
|
367
|
+
raise ProviderProbeError("probe_headers_invalid") from None
|
|
368
|
+
if declared < 0 or declared > maximum:
|
|
369
|
+
raise ProviderProbeError("probe_response_limit")
|
|
370
|
+
chunks: list[bytes] = []
|
|
371
|
+
size = 0
|
|
372
|
+
while True:
|
|
373
|
+
# Chunked responses (no Content-Length) end with http.client closing
|
|
374
|
+
# the socket under Connection: close; touching the dead socket again
|
|
375
|
+
# raises WinError 10038 on Windows, so stop once the response reports
|
|
376
|
+
# itself complete.
|
|
377
|
+
closed = getattr(response, "isclosed", None)
|
|
378
|
+
if callable(closed) and closed():
|
|
379
|
+
break
|
|
380
|
+
transport_socket.settimeout(_remaining(deadline, clock))
|
|
381
|
+
chunk = response.read(min(8192, maximum + 1 - size))
|
|
382
|
+
if not isinstance(chunk, bytes):
|
|
383
|
+
raise ProviderProbeError("probe_protocol_invalid")
|
|
384
|
+
if not chunk:
|
|
385
|
+
break
|
|
386
|
+
chunks.append(chunk)
|
|
387
|
+
size += len(chunk)
|
|
388
|
+
if size > maximum:
|
|
389
|
+
raise ProviderProbeError("probe_response_limit")
|
|
390
|
+
if declared is not None and size == declared:
|
|
391
|
+
break
|
|
392
|
+
if declared is not None and declared != size:
|
|
393
|
+
raise ProviderProbeError("probe_protocol_invalid")
|
|
394
|
+
body = b"".join(chunks)
|
|
395
|
+
try:
|
|
396
|
+
json.loads(body.decode("utf-8"))
|
|
397
|
+
except (UnicodeDecodeError, json.JSONDecodeError, RecursionError):
|
|
398
|
+
raise ProviderProbeError("probe_protocol_invalid") from None
|
|
399
|
+
return body
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def run_process_probe(
|
|
403
|
+
*,
|
|
404
|
+
argv: tuple[str, ...],
|
|
405
|
+
cwd: Path,
|
|
406
|
+
provider: LifecycleProviderId | str,
|
|
407
|
+
credential_home: Path | None,
|
|
408
|
+
timeout_seconds: float,
|
|
409
|
+
runner: ProcessRunner = run_bounded_process,
|
|
410
|
+
source_environment: Mapping[str, str] | None = None,
|
|
411
|
+
max_output_bytes: int = MAX_PROBE_RESPONSE_BYTES,
|
|
412
|
+
) -> CliProcessResult:
|
|
413
|
+
"""Run one fixed no-input CLI identity probe through the hardened transport."""
|
|
414
|
+
try:
|
|
415
|
+
lifecycle_provider = LifecycleProviderId(provider)
|
|
416
|
+
cli_provider = ProviderId(lifecycle_provider.value)
|
|
417
|
+
except (TypeError, ValueError):
|
|
418
|
+
raise ProviderProbeError("probe_provider_invalid") from None
|
|
419
|
+
try:
|
|
420
|
+
return run_cli_process(
|
|
421
|
+
argv=argv,
|
|
422
|
+
cwd=cwd,
|
|
423
|
+
stdin=b"",
|
|
424
|
+
provider=cli_provider,
|
|
425
|
+
credential_home=credential_home,
|
|
426
|
+
timeout_seconds=timeout_seconds,
|
|
427
|
+
max_input_bytes=1,
|
|
428
|
+
max_output_bytes=max_output_bytes,
|
|
429
|
+
runner=runner,
|
|
430
|
+
source_environment=source_environment,
|
|
431
|
+
)
|
|
432
|
+
except CliProcessError as exc:
|
|
433
|
+
raise ProviderProbeError(exc.code, diagnostics=exc.diagnostics) from None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def run_http_probe(
|
|
437
|
+
*,
|
|
438
|
+
endpoint: HttpProbeEndpoint,
|
|
439
|
+
timeout_seconds: float,
|
|
440
|
+
request_body: bytes | None = None,
|
|
441
|
+
authorization: str | None = None,
|
|
442
|
+
max_response_bytes: int = MAX_PROBE_RESPONSE_BYTES,
|
|
443
|
+
resolver: Resolver = socket.getaddrinfo,
|
|
444
|
+
connection_factory: ConnectionFactory = _default_connection,
|
|
445
|
+
clock: Callable[[], float] = time.monotonic,
|
|
446
|
+
) -> HttpProbeResult:
|
|
447
|
+
"""Perform one pinned, redirect-free metadata request under one deadline."""
|
|
448
|
+
if not isinstance(endpoint, HttpProbeEndpoint):
|
|
449
|
+
raise ProviderProbeError("probe_endpoint_invalid")
|
|
450
|
+
inference = endpoint.purpose in _INFERENCE_PURPOSES
|
|
451
|
+
timeout_ceiling = MAX_INFERENCE_TIMEOUT_SECONDS if inference else 30.0
|
|
452
|
+
request_ceiling = MAX_INFERENCE_REQUEST_BYTES if inference else MAX_PROBE_REQUEST_BYTES
|
|
453
|
+
if inference:
|
|
454
|
+
response_ceiling = MAX_INFERENCE_RESPONSE_BYTES
|
|
455
|
+
elif endpoint.purpose in _CATALOG_PURPOSES:
|
|
456
|
+
response_ceiling = MAX_CATALOG_RESPONSE_BYTES
|
|
457
|
+
else:
|
|
458
|
+
response_ceiling = MAX_PROBE_RESPONSE_BYTES
|
|
459
|
+
if (
|
|
460
|
+
isinstance(timeout_seconds, bool)
|
|
461
|
+
or not isinstance(timeout_seconds, (int, float))
|
|
462
|
+
or not math.isfinite(timeout_seconds)
|
|
463
|
+
or not 0.1 <= timeout_seconds <= timeout_ceiling
|
|
464
|
+
or isinstance(max_response_bytes, bool)
|
|
465
|
+
or not isinstance(max_response_bytes, int)
|
|
466
|
+
or not 1 <= max_response_bytes <= response_ceiling
|
|
467
|
+
or request_body is not None
|
|
468
|
+
and (
|
|
469
|
+
not isinstance(request_body, bytes)
|
|
470
|
+
or len(request_body) > request_ceiling
|
|
471
|
+
)
|
|
472
|
+
or authorization is not None
|
|
473
|
+
and (
|
|
474
|
+
not isinstance(authorization, str)
|
|
475
|
+
or not authorization
|
|
476
|
+
or "\x00" in authorization
|
|
477
|
+
or "\r" in authorization
|
|
478
|
+
or "\n" in authorization
|
|
479
|
+
or len(authorization) > 4096
|
|
480
|
+
)
|
|
481
|
+
):
|
|
482
|
+
raise ProviderProbeError("probe_request_invalid")
|
|
483
|
+
provider, method, path = _PURPOSE_POLICY[endpoint.purpose]
|
|
484
|
+
if provider is not endpoint.provider:
|
|
485
|
+
raise ProviderProbeError("probe_endpoint_invalid")
|
|
486
|
+
if (request_body is not None) is (endpoint.purpose not in _BODY_PURPOSES):
|
|
487
|
+
raise ProviderProbeError("probe_request_invalid")
|
|
488
|
+
if endpoint.provider is LifecycleProviderId.OLLAMA and authorization is not None:
|
|
489
|
+
raise ProviderProbeError("probe_request_invalid")
|
|
490
|
+
started = clock()
|
|
491
|
+
deadline = started + float(timeout_seconds)
|
|
492
|
+
addresses = _resolve(endpoint, deadline=deadline, resolver=resolver, clock=clock)
|
|
493
|
+
connection: _Connection | None = None
|
|
494
|
+
try:
|
|
495
|
+
connection = connection_factory(endpoint, addresses[0], _remaining(deadline, clock))
|
|
496
|
+
connection.connect()
|
|
497
|
+
if _peer_address(connection) not in addresses:
|
|
498
|
+
raise ProviderProbeError("probe_peer_invalid")
|
|
499
|
+
transport_socket = connection.sock
|
|
500
|
+
transport_socket.settimeout(_remaining(deadline, clock))
|
|
501
|
+
headers = {"Accept": "application/json", "Connection": "close"}
|
|
502
|
+
if authorization is not None:
|
|
503
|
+
headers["Authorization"] = authorization
|
|
504
|
+
if request_body is not None:
|
|
505
|
+
headers["Content-Type"] = "application/json"
|
|
506
|
+
headers["Content-Length"] = str(len(request_body))
|
|
507
|
+
connection.request(method, path, body=request_body, headers=headers)
|
|
508
|
+
transport_socket.settimeout(_remaining(deadline, clock))
|
|
509
|
+
response = connection.getresponse()
|
|
510
|
+
if 300 <= response.status <= 399:
|
|
511
|
+
raise ProviderProbeError("probe_redirect_rejected")
|
|
512
|
+
if not 200 <= response.status <= 299:
|
|
513
|
+
raise ProviderProbeError("probe_http_status")
|
|
514
|
+
body = _read_response(
|
|
515
|
+
response,
|
|
516
|
+
max_response_bytes,
|
|
517
|
+
transport_socket=transport_socket,
|
|
518
|
+
deadline=deadline,
|
|
519
|
+
clock=clock,
|
|
520
|
+
)
|
|
521
|
+
duration = clock() - started
|
|
522
|
+
if duration < 0 or duration > timeout_seconds:
|
|
523
|
+
raise ProviderProbeError("probe_timeout")
|
|
524
|
+
return HttpProbeResult(
|
|
525
|
+
response.status,
|
|
526
|
+
body,
|
|
527
|
+
hashlib.sha256(body).hexdigest(),
|
|
528
|
+
duration,
|
|
529
|
+
)
|
|
530
|
+
except ProviderProbeError:
|
|
531
|
+
raise
|
|
532
|
+
except (TimeoutError, socket.timeout):
|
|
533
|
+
raise ProviderProbeError("probe_timeout") from None
|
|
534
|
+
except (OSError, ssl.SSLError, http.client.HTTPException):
|
|
535
|
+
raise ProviderProbeError("probe_unavailable") from None
|
|
536
|
+
except Exception:
|
|
537
|
+
raise ProviderProbeError("probe_failed") from None
|
|
538
|
+
finally:
|
|
539
|
+
if connection is not None:
|
|
540
|
+
try:
|
|
541
|
+
connection.close()
|
|
542
|
+
except Exception:
|
|
543
|
+
pass
|