mcgyvr 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 (193) hide show
  1. mcgyvr/__init__.py +14 -0
  2. mcgyvr/availability.py +455 -0
  3. mcgyvr/capability.py +616 -0
  4. mcgyvr/capacity.py +1741 -0
  5. mcgyvr/catalog.py +428 -0
  6. mcgyvr/cleanup.py +330 -0
  7. mcgyvr/cli.py +4547 -0
  8. mcgyvr/compose.py +225 -0
  9. mcgyvr/config.py +2396 -0
  10. mcgyvr/consensus.py +417 -0
  11. mcgyvr/contract.py +1396 -0
  12. mcgyvr/cooldown.py +277 -0
  13. mcgyvr/data/capability-table.json +223 -0
  14. mcgyvr/data/model-catalog.json +33 -0
  15. mcgyvr/data/numbers.json +2209 -0
  16. mcgyvr/data/task-catalog.json +251 -0
  17. mcgyvr/decision.py +477 -0
  18. mcgyvr/delegate.py +651 -0
  19. mcgyvr/deliver.py +1713 -0
  20. mcgyvr/derived.py +590 -0
  21. mcgyvr/detect.py +550 -0
  22. mcgyvr/deterministic.py +516 -0
  23. mcgyvr/docgen.py +1011 -0
  24. mcgyvr/drive.py +1557 -0
  25. mcgyvr/emit.py +1072 -0
  26. mcgyvr/escalate.py +1781 -0
  27. mcgyvr/exits.py +28 -0
  28. mcgyvr/fleet/__init__.py +5 -0
  29. mcgyvr/fleet/admission.py +127 -0
  30. mcgyvr/fleet/admit.py +236 -0
  31. mcgyvr/fleet/alerts.py +484 -0
  32. mcgyvr/fleet/files.py +345 -0
  33. mcgyvr/fleet/harness.py +782 -0
  34. mcgyvr/fleet/ids.py +111 -0
  35. mcgyvr/fleet/layout.py +211 -0
  36. mcgyvr/fleet/linkread.py +221 -0
  37. mcgyvr/fleet/links.py +34 -0
  38. mcgyvr/fleet/lock.py +610 -0
  39. mcgyvr/fleet/machine.py +663 -0
  40. mcgyvr/fleet/probe.py +574 -0
  41. mcgyvr/fleet/promote.py +397 -0
  42. mcgyvr/fleet/read.py +1031 -0
  43. mcgyvr/fleet/roots.py +257 -0
  44. mcgyvr/fleet/spans.py +292 -0
  45. mcgyvr/fleet/tolerance.py +121 -0
  46. mcgyvr/fleet_manager.py +216 -0
  47. mcgyvr/gate/__init__.py +76 -0
  48. mcgyvr/gate/_engine/ghostcall/LICENSE +21 -0
  49. mcgyvr/gate/_engine/ghostcall/__init__.py +1 -0
  50. mcgyvr/gate/_engine/ghostcall/checker.py +78 -0
  51. mcgyvr/gate/_engine/ghostcall/parser.py +117 -0
  52. mcgyvr/gate/_engine/ghostcall/suggest.py +13 -0
  53. mcgyvr/gate/acceptance.py +482 -0
  54. mcgyvr/gate/adapter.py +474 -0
  55. mcgyvr/gate/adapters/__init__.py +13 -0
  56. mcgyvr/gate/adapters/javascript.py +450 -0
  57. mcgyvr/gate/adapters/python.py +642 -0
  58. mcgyvr/gate/changeset.py +372 -0
  59. mcgyvr/gate/findings.py +63 -0
  60. mcgyvr/gate/jev.py +260 -0
  61. mcgyvr/gate/output.py +1454 -0
  62. mcgyvr/gate/preflight.py +499 -0
  63. mcgyvr/gate/runner.py +359 -0
  64. mcgyvr/gate/secrets.py +99 -0
  65. mcgyvr/gate/semantic.py +346 -0
  66. mcgyvr/gate/semantic_driver.py +354 -0
  67. mcgyvr/gate/structured.py +90 -0
  68. mcgyvr/gate/typecheck.py +1373 -0
  69. mcgyvr/initialize.py +1149 -0
  70. mcgyvr/ladder_manager.py +943 -0
  71. mcgyvr/lines.py +61 -0
  72. mcgyvr/mcorch/__init__.py +13 -0
  73. mcgyvr/mcorch/anthropic.py +437 -0
  74. mcgyvr/mcorch/authoring.py +322 -0
  75. mcgyvr/mcorch/bind.py +155 -0
  76. mcgyvr/mcorch/evidence.py +198 -0
  77. mcgyvr/mcorch/guard.py +143 -0
  78. mcgyvr/mcorch/loop.py +621 -0
  79. mcgyvr/mcorch/prompt.py +64 -0
  80. mcgyvr/mcorch/serve.py +351 -0
  81. mcgyvr/mcorch/transcript.py +63 -0
  82. mcgyvr/mcorch/wire.py +53 -0
  83. mcgyvr/orchestrator/__init__.py +102 -0
  84. mcgyvr/orchestrator/cache.py +513 -0
  85. mcgyvr/orchestrator/context.py +315 -0
  86. mcgyvr/orchestrator/decompose.py +844 -0
  87. mcgyvr/orchestrator/index.py +405 -0
  88. mcgyvr/orchestrator/read.py +407 -0
  89. mcgyvr/orchestrator/repo.py +347 -0
  90. mcgyvr/orchestrator/resolve.py +530 -0
  91. mcgyvr/orchestrator/symbols.py +555 -0
  92. mcgyvr/pool.py +662 -0
  93. mcgyvr/pressure.py +674 -0
  94. mcgyvr/prompts/agent.md +8 -0
  95. mcgyvr/prompts/javascript.md +52 -0
  96. mcgyvr/prompts/mcorch.md +75 -0
  97. mcgyvr/prompts/media-gen.md +7 -0
  98. mcgyvr/prompts/python.md +48 -0
  99. mcgyvr/propose.py +232 -0
  100. mcgyvr/py.typed +0 -0
  101. mcgyvr/recommend.py +740 -0
  102. mcgyvr/redact.py +72 -0
  103. mcgyvr/rename.py +192 -0
  104. mcgyvr/repair.py +638 -0
  105. mcgyvr/result.py +135 -0
  106. mcgyvr/rig/__init__.py +40 -0
  107. mcgyvr/rig/agent.py +618 -0
  108. mcgyvr/rig/commands.py +102 -0
  109. mcgyvr/rig/credentials.py +164 -0
  110. mcgyvr/rig/hardware.py +181 -0
  111. mcgyvr/rig/hitchhike.py +510 -0
  112. mcgyvr/rig/inventory.py +193 -0
  113. mcgyvr/rig/outbox.py +98 -0
  114. mcgyvr/rig/probe.py +485 -0
  115. mcgyvr/rig/protocol.py +520 -0
  116. mcgyvr/rig/relay.py +835 -0
  117. mcgyvr/rig/rungs.py +575 -0
  118. mcgyvr/rig/session.py +2042 -0
  119. mcgyvr/rig/sessionwire.py +1070 -0
  120. mcgyvr/rig/sharing.py +289 -0
  121. mcgyvr/rig/state.py +77 -0
  122. mcgyvr/rig/tensorcache.py +307 -0
  123. mcgyvr/rig/tunnel.py +336 -0
  124. mcgyvr/rig/udpwire.py +338 -0
  125. mcgyvr/rig/verbs.py +726 -0
  126. mcgyvr/rig/websocket.py +537 -0
  127. mcgyvr/route.py +1275 -0
  128. mcgyvr/runner.py +1588 -0
  129. mcgyvr/sandbox/__init__.py +41 -0
  130. mcgyvr/sandbox/base.py +898 -0
  131. mcgyvr/sandbox/declared.py +150 -0
  132. mcgyvr/sandbox/docker.py +499 -0
  133. mcgyvr/sandbox/image.py +499 -0
  134. mcgyvr/sandbox/pooled-seccomp.json +860 -0
  135. mcgyvr/sandbox/pooled.py +987 -0
  136. mcgyvr/sandbox/stack.py +475 -0
  137. mcgyvr/sandbox/tempdir.py +189 -0
  138. mcgyvr/scan.py +1107 -0
  139. mcgyvr/scope.py +189 -0
  140. mcgyvr/serving/__init__.py +2755 -0
  141. mcgyvr/serving/gate-scripts/01-round.py +205 -0
  142. mcgyvr/serving/gate-scripts/02-rig.py +349 -0
  143. mcgyvr/serving/gate-scripts/03-image.py +98 -0
  144. mcgyvr/serving/gate-scripts/04-workload.py +43 -0
  145. mcgyvr/serving/gate-scripts/05-envelope.py +455 -0
  146. mcgyvr/serving/gate-scripts/06-step.py +87 -0
  147. mcgyvr/serving/gate-scripts/07-teardown.py +285 -0
  148. mcgyvr/serving/gate-scripts/08-parse.py +266 -0
  149. mcgyvr/serving/gate-scripts/bin/docker +37 -0
  150. mcgyvr/serving/gate-scripts/bin/ssh +39 -0
  151. mcgyvr/serving/gate-scripts/data-10-scan.py +86 -0
  152. mcgyvr/serving/gate-scripts/data-20-geometry.py +125 -0
  153. mcgyvr/serving/gate-scripts/data-30-placement.py +120 -0
  154. mcgyvr/serving/gate-scripts/default-step.sh +358 -0
  155. mcgyvr/serving/gate-scripts/lease-release.py +38 -0
  156. mcgyvr/serving/gate-scripts/link-01-time.py +62 -0
  157. mcgyvr/serving/gate-scripts/machine-read.sh +834 -0
  158. mcgyvr/serving/gate-scripts/read-01-profile.py +32 -0
  159. mcgyvr/serving/gate-scripts/read-02-rig.py +197 -0
  160. mcgyvr/serving/gate-scripts/rig-snapshot.sh +272 -0
  161. mcgyvr/serving/gate-scripts/rig-units.sh +147 -0
  162. mcgyvr/serving/gate-scripts/serve-down.py +65 -0
  163. mcgyvr/serving/gate-scripts/serve-sleep.py +103 -0
  164. mcgyvr/serving/gate-scripts/serve-up.py +105 -0
  165. mcgyvr/serving/gate-scripts/serve-wake.py +83 -0
  166. mcgyvr/serving/gatelib.py +1158 -0
  167. mcgyvr/serving/ggufscan.py +391 -0
  168. mcgyvr/serving/interconnect.py +409 -0
  169. mcgyvr/serving/linktime.py +311 -0
  170. mcgyvr/serving/rigscan.py +375 -0
  171. mcgyvr/serving/run.py +2153 -0
  172. mcgyvr/serving/safetensorscan.py +426 -0
  173. mcgyvr/serving/servelib.py +412 -0
  174. mcgyvr/serving/sharding.py +1258 -0
  175. mcgyvr/serving/shardlaunch.py +382 -0
  176. mcgyvr/serving/vramfit.py +507 -0
  177. mcgyvr/session.py +215 -0
  178. mcgyvr/strict_yaml.py +74 -0
  179. mcgyvr/telemetry.py +938 -0
  180. mcgyvr/triage.py +180 -0
  181. mcgyvr/verify.py +1038 -0
  182. mcgyvr/wake.py +1135 -0
  183. mcgyvr/weights.py +66 -0
  184. mcgyvr/whole.py +287 -0
  185. mcgyvr/worker/__init__.py +60 -0
  186. mcgyvr/worker/bundle.py +273 -0
  187. mcgyvr/worker/prompt.py +229 -0
  188. mcgyvr/worker/reply.py +573 -0
  189. mcgyvr-0.3.0.dist-info/METADATA +33 -0
  190. mcgyvr-0.3.0.dist-info/RECORD +193 -0
  191. mcgyvr-0.3.0.dist-info/WHEEL +4 -0
  192. mcgyvr-0.3.0.dist-info/entry_points.txt +2 -0
  193. mcgyvr-0.3.0.dist-info/licenses/LICENSE +21 -0
mcgyvr/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """mcgyvr — offload scoped coding work to a configurable worker ladder."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ #: The version of the product that is installed, as the wheel's metadata
7
+ #: says it — which is the tag it was built from (hatch-vcs). Read from
8
+ #: the metadata and never spelled here, so the code cannot claim a
9
+ #: version other than the one it was installed as.
10
+ __version__ = version("mcgyvr")
11
+ except PackageNotFoundError:
12
+ # The package is imported from a tree that was never installed — a
13
+ # fixture, a bare checkout on sys.path. Not a version, and said so.
14
+ __version__ = "0.0.0+uninstalled"
mcgyvr/availability.py ADDED
@@ -0,0 +1,455 @@
1
+ """Is anything actually there — per-source liveness, priced at one timeout a run.
2
+
3
+ :mod:`mcgyvr.pool` resolves the ladder *structurally*: a rung whose source names
4
+ a credential the environment does not hold is unusable, and that is knowable
5
+ without touching the network. This module answers the other half: a source that
6
+ is declared, credentialled and simply **down**.
7
+
8
+ **Two kinds of source, and they fail in different ways.** A source is an endpoint
9
+ and a wire protocol, so the same code reaches both — but reading the results
10
+ without keeping them apart is how a classification here goes wrong:
11
+
12
+ * A **local backend** is a server on hardware the user controls —
13
+ llama-server, vLLM, LM Studio, TGI. Typically keyless, on ``localhost`` or a
14
+ LAN address. Its characteristic failures are *the process is not running*
15
+ (connection refused, which returns instantly) and *the machine is off or
16
+ asleep* (no route or no SYN-ACK, which costs the whole timeout). It is also the
17
+ kind that produces the awkward **404**: a small server may implement
18
+ ``/v1/chat/completions`` and never implement ``/v1/models``.
19
+ * A **hosted provider** is somebody else's API behind a key. Its characteristic
20
+ failures are *the key is wrong, expired or revoked* (401/403), *the provider is
21
+ having an outage* (5xx), and *this machine has no working internet*
22
+ (timeout). It essentially never 404s at the model listing, because publishing
23
+ one is table stakes for a commercial API.
24
+
25
+ The distinction is descriptive, not a type: nothing here branches on it, because
26
+ nothing reliably tells them apart — a local server can require a key, and a
27
+ self-hosted vLLM can sit behind a public hostname. What it does is explain why
28
+ the classification below is asymmetric. **The 401 arm is there for hosted
29
+ providers and the 404 arm is there for local backends**, and each would look
30
+ like an over-reaction if only the other kind existed.
31
+
32
+ It is also why :attr:`~mcgyvr.pool.Endpoint.credential_env` is reported rather
33
+ than assumed: the reason text names the variable when there is one and says that
34
+ none is configured when there is not, so the message is actionable for whichever
35
+ kind it turns out to be.
36
+
37
+ Distinct from :mod:`mcgyvr.detect`, which is install-time and local-only: it
38
+ sweeps *candidate* default ports (:data:`mcgyvr.detect.PORT_CONVENTIONS`) to find
39
+ out what this machine happens to be running. This module probes the sources a config
40
+ actually *declares*, local or hosted, and does it per run.
41
+
42
+ The shape of the problem is a cost problem, not a detection problem. Finding out
43
+ that a host is not answering is easy; finding it out once is the work. A ladder
44
+ escalates — a task that fails on the cheap rung is retried on the next — so a
45
+ dead source that is discovered at dispatch time is discovered again on every
46
+ attempt, and each discovery costs a connect timeout. So:
47
+
48
+ * **A verdict is cached for the life of an** :class:`Availability`. One instance
49
+ per run; a source is probed at most once, however many times it is asked
50
+ for. The cache has no expiry on purpose — a run is short, and a source that
51
+ comes back mid-run being missed is a better failure than a re-probe storm at
52
+ every escalation.
53
+ * **A batch is probed concurrently**, so the wall clock for *n* dead sources is
54
+ one timeout rather than *n*. This is the same trick :mod:`mcgyvr.detect` plays
55
+ at install time, for the same reason.
56
+ * **The timeout is short and is not the dispatch timeout.**
57
+ :data:`PROBE_TIMEOUT_S` is seconds where
58
+ :data:`~mcgyvr.runner.GENERATE_TIMEOUT_S` is two minutes. They measure
59
+ different things: a local 7B taking ninety seconds to answer a capped
60
+ generation is healthy, while a host taking ninety seconds to accept a TCP
61
+ connection is not. Reusing the dispatch timeout here would make "is anything
62
+ there" cost as much as asking it something.
63
+
64
+ **A probe is a question about the source, never a generation.** It reads the
65
+ protocol's model-list path, which is free, immediate and side-effect-free. It
66
+ deliberately does *not* send a completion to see whether one comes back: that
67
+ costs tokens and seconds, and it conflates two failures a caller has to tell
68
+ apart — a host that is down and a model that is not loaded. Whether a *model* is
69
+ present is :meth:`mcgyvr.detect.Backend.has_model`'s question, asked at install
70
+ time against what the backend reports holding.
71
+
72
+ **Any HTTP answer proves something is listening; the question is whether a
73
+ dispatch would work.** That distinction decides the whole classification, and it
74
+ is why this is not simply "2xx is up":
75
+
76
+ * **Transport failure** — refused, DNS failure, timeout — is down. Nothing is
77
+ listening, or nothing is listening fast enough to be worth a dispatch. Both
78
+ kinds land here; only the wall clock differs, since a refused connection to a
79
+ local port is instant where an unreachable host costs the whole budget.
80
+ * **401 and 403 are down.** *Chiefly the hosted-provider case*, and the one worth
81
+ stating: the source *is* answering, so a naive reachability check would call it
82
+ live and hand it dispatches that all fail identically. A key that
83
+ is wrong, expired or revoked does not improve with retries. Note this is a
84
+ different fault from the one :func:`mcgyvr.pool.source_map` already catches
85
+ structurally — there the variable is *unset*, here it is set and rejected.
86
+ * **5xx is down.** The server is there and is telling us it cannot serve. A
87
+ provider outage, most often.
88
+ * **404 and 405 are LIVE**, the classification most likely to look like a bug and
89
+ the one that matters most. *Chiefly the local-backend case*: the model-list
90
+ path is optional, and a small OpenAI-compatible server may serve
91
+ ``/v1/chat/completions`` perfectly well while never implementing
92
+ ``/v1/models``. Reading that as down would skip a *working* source — a false
93
+ negative that silently shortens the ladder, which is strictly worse than the
94
+ wasted attempt it would be trying to save. Anything that answered HTTP without
95
+ refusing us on credentials is treated as reachable.
96
+
97
+ **What this does not do.** It does not retry: one probe, one verdict, and a
98
+ source that was down when the run started stays down for that run. It does not
99
+ bound concurrency — that is :mod:`mcgyvr.capacity`. It does not decide *which*
100
+ rung a contract goes to, only which rungs are on the ladder at all.
101
+ """
102
+
103
+ from __future__ import annotations
104
+
105
+ import json
106
+ import time
107
+ import urllib.error
108
+ import urllib.request
109
+ from collections.abc import Callable, Mapping, Sequence
110
+ from concurrent.futures import ThreadPoolExecutor
111
+ from dataclasses import dataclass
112
+
113
+ from mcgyvr.pool import Endpoint, PoolError, Protocol
114
+ from mcgyvr.redact import safe_url
115
+ from mcgyvr.weights import is_model
116
+
117
+ # Short by design, and far below the dispatch timeout: this bounds "did anything
118
+ # accept a connection and answer", not "did a model finish thinking". A local
119
+ # host that cannot answer a model list in this long is not going to serve a
120
+ # generation.
121
+ PROBE_TIMEOUT_S = 2.0
122
+
123
+ # How much of a model listing is read. A listing is a handful of ids and this is
124
+ # generous for one; a source that answers with more than this is a source whose
125
+ # body is not being read to the end, and the answer is then the same as an
126
+ # unreadable one — `None`, which claims nothing.
127
+ _LIST_BODY_BYTES = 1 << 20
128
+
129
+ # The free, side-effect-free listing each protocol offers. Chosen over a
130
+ # generation for the reason in the module docstring, and over a bare TCP connect
131
+ # because a connect proves a socket is open, not that an HTTP server is behind
132
+ # it.
133
+ _LIST_PATH: dict[Protocol, str] = {
134
+ Protocol.OPENAI: "/v1/models",
135
+ }
136
+
137
+ # Statuses that mean "answering, but a dispatch would fail the same way every
138
+ # time". Everything else that answered HTTP at all is read as reachable — see
139
+ # the docstring on why 404 must not be in here.
140
+ _CREDENTIAL_REFUSED = frozenset({401, 403})
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class AvailabilityVerdict:
145
+ """What one probe found, and how it found it.
146
+
147
+ ``reason`` is written to be read by whoever sees a shortened ladder, because
148
+ it becomes :attr:`mcgyvr.pool.Skipped.reason` verbatim. ``how`` follows
149
+ :mod:`mcgyvr.detect`'s rule that a detected fact carries its provenance: a
150
+ verdict with no account of how it was reached is indistinguishable from a
151
+ guess, and this one shortens a ladder.
152
+ """
153
+
154
+ source: str
155
+ live: bool
156
+ reason: str
157
+ how: str
158
+ elapsed_s: float
159
+ #: The model ids this endpoint said it is holding, or ``None`` where it did
160
+ #: not say. The distinction is the whole of :meth:`Availability.not_serving`
161
+ #: and it is not a nicety: a 404 on the listing, a body that is not JSON, a
162
+ #: body of another shape and an empty list are all ``None``, because the
163
+ #: model-list path is optional and a server may not publish a usable one.
164
+ #: Only an explicit, readable listing may take a rung out of service: a
165
+ #: probe that failed closed on an unreadable answer would empty the ladder.
166
+ models: tuple[str, ...] | None = None
167
+
168
+
169
+ ProbeFn = Callable[[Endpoint, float], AvailabilityVerdict]
170
+
171
+
172
+ class Availability:
173
+ """Per-source liveness for one run, probed at most once per source.
174
+
175
+ Construct one per run and pass it to :func:`mcgyvr.pool.source_map`. Holding
176
+ it for longer is the one thing that breaks it: the cache never expires, so a
177
+ long-lived instance would keep reporting a verdict from whenever it first
178
+ looked.
179
+
180
+ ``probe`` exists so the classification can be tested without a network and so
181
+ a caller with its own transport can supply one; the default is
182
+ :func:`probe_endpoint`.
183
+ """
184
+
185
+ def __init__(
186
+ self,
187
+ timeout_s: float = PROBE_TIMEOUT_S,
188
+ probe: ProbeFn | None = None,
189
+ ) -> None:
190
+ if timeout_s <= 0:
191
+ raise ValueError(f"timeout_s must be positive, got {timeout_s}")
192
+ self._timeout_s = timeout_s
193
+ self._probe = probe or probe_endpoint
194
+ self._verdicts: dict[str, AvailabilityVerdict] = {}
195
+
196
+ @property
197
+ def verdicts(self) -> Mapping[str, AvailabilityVerdict]:
198
+ """Every verdict reached so far, by source name."""
199
+ return dict(self._verdicts)
200
+
201
+ def check(self, endpoint: Endpoint) -> AvailabilityVerdict:
202
+ """The verdict for this endpoint's source, probing only if unseen."""
203
+ cached = self._verdicts.get(endpoint.source)
204
+ if cached is not None:
205
+ return cached
206
+ verdict = self._probe(endpoint, self._timeout_s)
207
+ self._verdicts[endpoint.source] = verdict
208
+ return verdict
209
+
210
+ def check_all(
211
+ self, endpoints: Sequence[Endpoint]
212
+ ) -> Mapping[str, AvailabilityVerdict]:
213
+ """Probe every unseen source at once; return the verdict for each.
214
+
215
+ Concurrent because the cost being managed is wall clock: ``n`` dead
216
+ sources probed in sequence is ``n`` timeouts, and probed together is one.
217
+ Sources already in the cache are not re-probed, so calling this twice in
218
+ a run costs nothing the second time.
219
+ """
220
+ wanted = _distinct(endpoints)
221
+ fresh = [e for e in wanted if e.source not in self._verdicts]
222
+ if fresh:
223
+ with ThreadPoolExecutor(max_workers=len(fresh)) as pool:
224
+ for verdict in pool.map(
225
+ lambda e: self._probe(e, self._timeout_s), fresh
226
+ ):
227
+ self._verdicts[verdict.source] = verdict
228
+ return {e.source: self._verdicts[e.source] for e in wanted}
229
+
230
+ def not_serving(
231
+ self, bound: Sequence[tuple[Endpoint, str]]
232
+ ) -> Mapping[tuple[str, str], str]:
233
+ """Which (source, model) pairs the port is not currently holding, and why.
234
+
235
+ **The routing hole this closes.** :meth:`unavailable` answers whether a
236
+ source's address answers. Units that alternate on one card may declare
237
+ the same address, only one of them is up at a time, and the address
238
+ answers 200 for whichever that is. llama-server started on one model
239
+ ignores a request's ``model`` field: a dispatch aimed at the unit that
240
+ is not up is answered from the resident one's weights, and the server
241
+ reports no error. A ladder that marked both rungs up would spend a
242
+ request on a rung that is not up before the runner refused the answer
243
+ (:class:`~mcgyvr.runner.WrongWeightsError`).
244
+
245
+ So the question a rung needs answered is not "did the port answer" but
246
+ "is *this model* what is behind it", and the listing the probe already
247
+ fetched is where that is written. **No second request is made**: the
248
+ verdicts are the cached ones :meth:`check_all` produced.
249
+
250
+ A source whose verdict carries no listing (:attr:`AvailabilityVerdict.models`
251
+ is ``None``) is not reported here at all. It said nothing, and nothing is
252
+ not a no.
253
+ """
254
+ wanted = _distinct([endpoint for endpoint, _ in bound])
255
+ self.check_all(wanted)
256
+ not_there: dict[tuple[str, str], str] = {}
257
+ for endpoint, model in bound:
258
+ verdict = self._verdicts.get(endpoint.source)
259
+ if verdict is None or not verdict.live or not verdict.models:
260
+ continue
261
+ if any(is_model(served, model) for served in verdict.models):
262
+ continue
263
+ not_there[(endpoint.source, model)] = (
264
+ f"source {endpoint.source!r} is serving "
265
+ f"{', '.join(verdict.models)} and not {model!r}. The port "
266
+ f"answers, so a dispatch would be taken — and llama.cpp answers "
267
+ f"a request naming weights it is not holding from the weights it "
268
+ f"is, so the reply would be the wrong model's with nothing "
269
+ f"reporting it. Wake the card that serves {model!r}, or point "
270
+ f"the rung at the model that is up"
271
+ )
272
+ return not_there
273
+
274
+ def unavailable(self, endpoints: Sequence[Endpoint]) -> Mapping[str, str]:
275
+ """Which of these sources cannot serve, and why — the pool's seam.
276
+
277
+ Deliberately the narrowest thing :func:`mcgyvr.pool.source_map` could
278
+ need: a mapping of source name to reason, holding only the sources that
279
+ are down. :mod:`mcgyvr.pool` therefore learns nothing about probes,
280
+ verdicts or HTTP, and this module stays the only place that knows the
281
+ network exists.
282
+ """
283
+ return {
284
+ source: verdict.reason
285
+ for source, verdict in self.check_all(endpoints).items()
286
+ if not verdict.live
287
+ }
288
+
289
+
290
+ def probe_endpoint(
291
+ endpoint: Endpoint, timeout_s: float = PROBE_TIMEOUT_S
292
+ ) -> AvailabilityVerdict:
293
+ """Ask one endpoint whether it is there. Never raises.
294
+
295
+ Every outcome is a :class:`AvailabilityVerdict`, including the ones that
296
+ would ordinarily be exceptions: a probe that raised would have to be wrapped
297
+ at every call site, and the whole point is that an unreachable source is an
298
+ ordinary state of the world rather than an error in the program.
299
+ """
300
+ path = _LIST_PATH[endpoint.protocol]
301
+ url = endpoint.base_url.rstrip("/") + path
302
+ started = time.monotonic()
303
+
304
+ try:
305
+ headers = {}
306
+ key = endpoint.credential()
307
+ if key:
308
+ headers["Authorization"] = f"Bearer {key}"
309
+ except PoolError as exc:
310
+ # The credential vanished between the structural pass and here. Not
311
+ # reachability, but the rung is just as unusable and saying so beats
312
+ # probing with no key and reporting a 401.
313
+ return _verdict(endpoint, False, str(exc), "credential unresolved", started)
314
+
315
+ request = urllib.request.Request(url, headers=headers, method="GET")
316
+ try:
317
+ with urllib.request.urlopen(request, timeout=timeout_s) as response:
318
+ status = response.status
319
+ # Read here and not in a second call: the listing is the answer to
320
+ # "which weights are behind this port", and the request that asked
321
+ # for it has already been paid for. Bounded, because a probe is not
322
+ # a download and a source that answered with a gigabyte of JSON is a
323
+ # source this is not going to read all of.
324
+ body = response.read(_LIST_BODY_BYTES)
325
+ except urllib.error.HTTPError as exc:
326
+ return _from_status(endpoint, exc.code, url, started)
327
+ except OSError as exc:
328
+ # URLError and the socket timeout are both OSError, and to a caller they
329
+ # mean one thing: nothing usable answered inside the budget.
330
+ return _verdict(
331
+ endpoint,
332
+ False,
333
+ f"source {endpoint.source!r} did not answer at "
334
+ f"{safe_url(endpoint.base_url)} "
335
+ f"within {timeout_s:g}s ({exc})",
336
+ f"GET {path} raised {type(exc).__name__} within {timeout_s:g}s",
337
+ started,
338
+ )
339
+
340
+ return _from_status(endpoint, status, url, started, models=_listed(body))
341
+
342
+
343
+ def _listed(body: bytes) -> tuple[str, ...] | None:
344
+ """The model ids in an OpenAI-shaped listing, or ``None`` if it did not say.
345
+
346
+ Every way of not saying is ``None`` and never an empty tuple: a body that is
347
+ not JSON, a document of another shape, a ``data`` that is not a list, a list
348
+ whose entries carry no ``id``. An empty listing is ``None`` too — a server
349
+ that publishes the route and lists nothing has told us nothing about what it
350
+ is holding, and reading that as "your model is not here" would take the rung
351
+ out of service on the strength of a server's own omission.
352
+ """
353
+ try:
354
+ doc = json.loads(body.decode("utf-8", "replace"))
355
+ except ValueError:
356
+ return None
357
+ if not isinstance(doc, dict):
358
+ return None
359
+ rows = doc.get("data")
360
+ if not isinstance(rows, list):
361
+ return None
362
+ ids = tuple(
363
+ str(row["id"])
364
+ for row in rows
365
+ if isinstance(row, dict) and isinstance(row.get("id"), str)
366
+ )
367
+ return ids or None
368
+
369
+
370
+ def _from_status(
371
+ endpoint: Endpoint,
372
+ status: int,
373
+ url: str,
374
+ started: float,
375
+ models: tuple[str, ...] | None = None,
376
+ ) -> AvailabilityVerdict:
377
+ """Read an HTTP status as a liveness verdict.
378
+
379
+ The asymmetry is deliberate and is argued in the module docstring: a
380
+ credential refusal and a server error are down, and *everything else that
381
+ answered* is live — including 404, because the model-list path is optional
382
+ and a source that does not offer it may still serve generations.
383
+ """
384
+ if status in _CREDENTIAL_REFUSED:
385
+ named = (
386
+ f"${endpoint.credential_env}"
387
+ if endpoint.credential_env
388
+ else "no credential is configured"
389
+ )
390
+ return _verdict(
391
+ endpoint,
392
+ False,
393
+ f"source {endpoint.source!r} answered HTTP {status}: it is reachable "
394
+ f"but refused the credential ({named}). Every dispatch to it would "
395
+ f"fail the same way",
396
+ f"GET {safe_url(url)} answered {status}",
397
+ started,
398
+ )
399
+ if status >= 500:
400
+ return _verdict(
401
+ endpoint,
402
+ False,
403
+ f"source {endpoint.source!r} answered HTTP {status}: it is reachable "
404
+ f"but reports it cannot serve",
405
+ f"GET {safe_url(url)} answered {status}",
406
+ started,
407
+ )
408
+ if status >= 400:
409
+ return _verdict(
410
+ endpoint,
411
+ True,
412
+ "",
413
+ f"GET {safe_url(url)} answered {status}; read as reachable because the "
414
+ f"model-list path is optional and a dispatch may still succeed",
415
+ started,
416
+ )
417
+ return _verdict(
418
+ endpoint,
419
+ True,
420
+ "",
421
+ f"GET {safe_url(url)} answered {status}"
422
+ + (f", serving {', '.join(models)}" if models else ""),
423
+ started,
424
+ models=models,
425
+ )
426
+
427
+
428
+ def _verdict(
429
+ endpoint: Endpoint,
430
+ live: bool,
431
+ reason: str,
432
+ how: str,
433
+ started: float,
434
+ models: tuple[str, ...] | None = None,
435
+ ) -> AvailabilityVerdict:
436
+ return AvailabilityVerdict(
437
+ source=endpoint.source,
438
+ live=live,
439
+ reason=reason,
440
+ how=how,
441
+ elapsed_s=time.monotonic() - started,
442
+ models=models,
443
+ )
444
+
445
+
446
+ def _distinct(endpoints: Sequence[Endpoint]) -> tuple[Endpoint, ...]:
447
+ """One endpoint per source name, first occurrence winning, order kept.
448
+
449
+ Ordering is preserved so a report reads in ladder order rather than in
450
+ whatever order a set iterated.
451
+ """
452
+ seen: dict[str, Endpoint] = {}
453
+ for endpoint in endpoints:
454
+ seen.setdefault(endpoint.source, endpoint)
455
+ return tuple(seen.values())