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.
- mcgyvr/__init__.py +14 -0
- mcgyvr/availability.py +455 -0
- mcgyvr/capability.py +616 -0
- mcgyvr/capacity.py +1741 -0
- mcgyvr/catalog.py +428 -0
- mcgyvr/cleanup.py +330 -0
- mcgyvr/cli.py +4547 -0
- mcgyvr/compose.py +225 -0
- mcgyvr/config.py +2396 -0
- mcgyvr/consensus.py +417 -0
- mcgyvr/contract.py +1396 -0
- mcgyvr/cooldown.py +277 -0
- mcgyvr/data/capability-table.json +223 -0
- mcgyvr/data/model-catalog.json +33 -0
- mcgyvr/data/numbers.json +2209 -0
- mcgyvr/data/task-catalog.json +251 -0
- mcgyvr/decision.py +477 -0
- mcgyvr/delegate.py +651 -0
- mcgyvr/deliver.py +1713 -0
- mcgyvr/derived.py +590 -0
- mcgyvr/detect.py +550 -0
- mcgyvr/deterministic.py +516 -0
- mcgyvr/docgen.py +1011 -0
- mcgyvr/drive.py +1557 -0
- mcgyvr/emit.py +1072 -0
- mcgyvr/escalate.py +1781 -0
- mcgyvr/exits.py +28 -0
- mcgyvr/fleet/__init__.py +5 -0
- mcgyvr/fleet/admission.py +127 -0
- mcgyvr/fleet/admit.py +236 -0
- mcgyvr/fleet/alerts.py +484 -0
- mcgyvr/fleet/files.py +345 -0
- mcgyvr/fleet/harness.py +782 -0
- mcgyvr/fleet/ids.py +111 -0
- mcgyvr/fleet/layout.py +211 -0
- mcgyvr/fleet/linkread.py +221 -0
- mcgyvr/fleet/links.py +34 -0
- mcgyvr/fleet/lock.py +610 -0
- mcgyvr/fleet/machine.py +663 -0
- mcgyvr/fleet/probe.py +574 -0
- mcgyvr/fleet/promote.py +397 -0
- mcgyvr/fleet/read.py +1031 -0
- mcgyvr/fleet/roots.py +257 -0
- mcgyvr/fleet/spans.py +292 -0
- mcgyvr/fleet/tolerance.py +121 -0
- mcgyvr/fleet_manager.py +216 -0
- mcgyvr/gate/__init__.py +76 -0
- mcgyvr/gate/_engine/ghostcall/LICENSE +21 -0
- mcgyvr/gate/_engine/ghostcall/__init__.py +1 -0
- mcgyvr/gate/_engine/ghostcall/checker.py +78 -0
- mcgyvr/gate/_engine/ghostcall/parser.py +117 -0
- mcgyvr/gate/_engine/ghostcall/suggest.py +13 -0
- mcgyvr/gate/acceptance.py +482 -0
- mcgyvr/gate/adapter.py +474 -0
- mcgyvr/gate/adapters/__init__.py +13 -0
- mcgyvr/gate/adapters/javascript.py +450 -0
- mcgyvr/gate/adapters/python.py +642 -0
- mcgyvr/gate/changeset.py +372 -0
- mcgyvr/gate/findings.py +63 -0
- mcgyvr/gate/jev.py +260 -0
- mcgyvr/gate/output.py +1454 -0
- mcgyvr/gate/preflight.py +499 -0
- mcgyvr/gate/runner.py +359 -0
- mcgyvr/gate/secrets.py +99 -0
- mcgyvr/gate/semantic.py +346 -0
- mcgyvr/gate/semantic_driver.py +354 -0
- mcgyvr/gate/structured.py +90 -0
- mcgyvr/gate/typecheck.py +1373 -0
- mcgyvr/initialize.py +1149 -0
- mcgyvr/ladder_manager.py +943 -0
- mcgyvr/lines.py +61 -0
- mcgyvr/mcorch/__init__.py +13 -0
- mcgyvr/mcorch/anthropic.py +437 -0
- mcgyvr/mcorch/authoring.py +322 -0
- mcgyvr/mcorch/bind.py +155 -0
- mcgyvr/mcorch/evidence.py +198 -0
- mcgyvr/mcorch/guard.py +143 -0
- mcgyvr/mcorch/loop.py +621 -0
- mcgyvr/mcorch/prompt.py +64 -0
- mcgyvr/mcorch/serve.py +351 -0
- mcgyvr/mcorch/transcript.py +63 -0
- mcgyvr/mcorch/wire.py +53 -0
- mcgyvr/orchestrator/__init__.py +102 -0
- mcgyvr/orchestrator/cache.py +513 -0
- mcgyvr/orchestrator/context.py +315 -0
- mcgyvr/orchestrator/decompose.py +844 -0
- mcgyvr/orchestrator/index.py +405 -0
- mcgyvr/orchestrator/read.py +407 -0
- mcgyvr/orchestrator/repo.py +347 -0
- mcgyvr/orchestrator/resolve.py +530 -0
- mcgyvr/orchestrator/symbols.py +555 -0
- mcgyvr/pool.py +662 -0
- mcgyvr/pressure.py +674 -0
- mcgyvr/prompts/agent.md +8 -0
- mcgyvr/prompts/javascript.md +52 -0
- mcgyvr/prompts/mcorch.md +75 -0
- mcgyvr/prompts/media-gen.md +7 -0
- mcgyvr/prompts/python.md +48 -0
- mcgyvr/propose.py +232 -0
- mcgyvr/py.typed +0 -0
- mcgyvr/recommend.py +740 -0
- mcgyvr/redact.py +72 -0
- mcgyvr/rename.py +192 -0
- mcgyvr/repair.py +638 -0
- mcgyvr/result.py +135 -0
- mcgyvr/rig/__init__.py +40 -0
- mcgyvr/rig/agent.py +618 -0
- mcgyvr/rig/commands.py +102 -0
- mcgyvr/rig/credentials.py +164 -0
- mcgyvr/rig/hardware.py +181 -0
- mcgyvr/rig/hitchhike.py +510 -0
- mcgyvr/rig/inventory.py +193 -0
- mcgyvr/rig/outbox.py +98 -0
- mcgyvr/rig/probe.py +485 -0
- mcgyvr/rig/protocol.py +520 -0
- mcgyvr/rig/relay.py +835 -0
- mcgyvr/rig/rungs.py +575 -0
- mcgyvr/rig/session.py +2042 -0
- mcgyvr/rig/sessionwire.py +1070 -0
- mcgyvr/rig/sharing.py +289 -0
- mcgyvr/rig/state.py +77 -0
- mcgyvr/rig/tensorcache.py +307 -0
- mcgyvr/rig/tunnel.py +336 -0
- mcgyvr/rig/udpwire.py +338 -0
- mcgyvr/rig/verbs.py +726 -0
- mcgyvr/rig/websocket.py +537 -0
- mcgyvr/route.py +1275 -0
- mcgyvr/runner.py +1588 -0
- mcgyvr/sandbox/__init__.py +41 -0
- mcgyvr/sandbox/base.py +898 -0
- mcgyvr/sandbox/declared.py +150 -0
- mcgyvr/sandbox/docker.py +499 -0
- mcgyvr/sandbox/image.py +499 -0
- mcgyvr/sandbox/pooled-seccomp.json +860 -0
- mcgyvr/sandbox/pooled.py +987 -0
- mcgyvr/sandbox/stack.py +475 -0
- mcgyvr/sandbox/tempdir.py +189 -0
- mcgyvr/scan.py +1107 -0
- mcgyvr/scope.py +189 -0
- mcgyvr/serving/__init__.py +2755 -0
- mcgyvr/serving/gate-scripts/01-round.py +205 -0
- mcgyvr/serving/gate-scripts/02-rig.py +349 -0
- mcgyvr/serving/gate-scripts/03-image.py +98 -0
- mcgyvr/serving/gate-scripts/04-workload.py +43 -0
- mcgyvr/serving/gate-scripts/05-envelope.py +455 -0
- mcgyvr/serving/gate-scripts/06-step.py +87 -0
- mcgyvr/serving/gate-scripts/07-teardown.py +285 -0
- mcgyvr/serving/gate-scripts/08-parse.py +266 -0
- mcgyvr/serving/gate-scripts/bin/docker +37 -0
- mcgyvr/serving/gate-scripts/bin/ssh +39 -0
- mcgyvr/serving/gate-scripts/data-10-scan.py +86 -0
- mcgyvr/serving/gate-scripts/data-20-geometry.py +125 -0
- mcgyvr/serving/gate-scripts/data-30-placement.py +120 -0
- mcgyvr/serving/gate-scripts/default-step.sh +358 -0
- mcgyvr/serving/gate-scripts/lease-release.py +38 -0
- mcgyvr/serving/gate-scripts/link-01-time.py +62 -0
- mcgyvr/serving/gate-scripts/machine-read.sh +834 -0
- mcgyvr/serving/gate-scripts/read-01-profile.py +32 -0
- mcgyvr/serving/gate-scripts/read-02-rig.py +197 -0
- mcgyvr/serving/gate-scripts/rig-snapshot.sh +272 -0
- mcgyvr/serving/gate-scripts/rig-units.sh +147 -0
- mcgyvr/serving/gate-scripts/serve-down.py +65 -0
- mcgyvr/serving/gate-scripts/serve-sleep.py +103 -0
- mcgyvr/serving/gate-scripts/serve-up.py +105 -0
- mcgyvr/serving/gate-scripts/serve-wake.py +83 -0
- mcgyvr/serving/gatelib.py +1158 -0
- mcgyvr/serving/ggufscan.py +391 -0
- mcgyvr/serving/interconnect.py +409 -0
- mcgyvr/serving/linktime.py +311 -0
- mcgyvr/serving/rigscan.py +375 -0
- mcgyvr/serving/run.py +2153 -0
- mcgyvr/serving/safetensorscan.py +426 -0
- mcgyvr/serving/servelib.py +412 -0
- mcgyvr/serving/sharding.py +1258 -0
- mcgyvr/serving/shardlaunch.py +382 -0
- mcgyvr/serving/vramfit.py +507 -0
- mcgyvr/session.py +215 -0
- mcgyvr/strict_yaml.py +74 -0
- mcgyvr/telemetry.py +938 -0
- mcgyvr/triage.py +180 -0
- mcgyvr/verify.py +1038 -0
- mcgyvr/wake.py +1135 -0
- mcgyvr/weights.py +66 -0
- mcgyvr/whole.py +287 -0
- mcgyvr/worker/__init__.py +60 -0
- mcgyvr/worker/bundle.py +273 -0
- mcgyvr/worker/prompt.py +229 -0
- mcgyvr/worker/reply.py +573 -0
- mcgyvr-0.3.0.dist-info/METADATA +33 -0
- mcgyvr-0.3.0.dist-info/RECORD +193 -0
- mcgyvr-0.3.0.dist-info/WHEEL +4 -0
- mcgyvr-0.3.0.dist-info/entry_points.txt +2 -0
- 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())
|