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,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