animap-gpu / app /providers.py
bluman1's picture
Publish services/inference
4b98524 verified
Raw
History Blame Contribute Delete
31.9 kB
"""Model adapters, and the governance around them.
ADR 0005 and brief Β§30. A model file is only usable once it can account for
itself: explicit source, licence, checksum, model card, version, and validation
notes. Nothing is downloaded at runtime.
This is not bureaucracy. A livestock record that informs a treatment or a sale
has to be reconstructable months later β€” "which model said this animal weighed
386 kg, and had anyone checked it worked in Nigeria?" is a question the system
must be able to answer, and it cannot if artefacts arrive anonymously.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
from dataclasses import dataclass
from pathlib import Path
from app.adapters import fingerprints, licence_policy, licences
from app.capabilities import UNVALIDATED_GEOGRAPHY, Capability
logger = logging.getLogger(__name__)
#: Read in blocks rather than whole. `yolox_x` is 396 MB and this runs on a
#: 2 vCPU / 4 GiB container at start-up, next to onnxruntime's arenas.
_DIGEST_CHUNK = 1 << 20
MODELS_DIR = Path(__file__).resolve().parent.parent / "models"
#: Every field a model card must carry before its artefact may be loaded.
REQUIRED_CARD_FIELDS = (
"model_id",
"version",
"capability_key",
"source",
"license",
"sha256",
"validation_notes",
"geography_validated",
)
class ArtefactError(RuntimeError):
"""An artefact is missing, unaccounted for, or does not match its card."""
#: Licences Animap may not serve from. Copyleft that reaches a network service,
#: and non-commercial terms that a commercial product cannot satisfy at all.
#:
#: ADR 0017 records what AGPL-3.0 actually says, which is narrower than this
#: service used to claim: Β§13's source-offer duty is conditioned on *modifying*
#: the program, and Β§0 puts network interaction outside "convey". The reason
#: these are refused anyway is not that breach is certain β€” it is that the
#: vendor's published position asserts it, the question is unsettled, and the
#: measured cost of avoiding it is roughly nil.
#:
#: The list is broader than AGPL because the failure it guards against is "an
#: artefact whose terms this product cannot meet", and a Non-Commercial dataset
#: licence fails that just as completely. The Bristol cattle sets are the live
#: example (`app/dispositions.py`).
DISALLOWED_LICENSES = frozenset({
"AGPL-3.0", "AGPL-3.0-only", "AGPL-3.0-or-later",
"GPL-3.0", "GPL-3.0-only", "GPL-3.0-or-later", "GPL-2.0",
"SSPL-1.0",
"CC-BY-NC-4.0", "CC-BY-NC-SA-4.0", "CC BY-NC 4.0",
"Non-Commercial Government Licence",
})
#: Kept as the old name because `is_copyleft` reads better at the call site and
#: the AGPL case is still the one this exists for.
NETWORK_COPYLEFT_LICENSES = DISALLOWED_LICENSES
#: Runtimes that can only load an artefact whose terms Animap may not serve
#: under, whatever the card claims about it.
#:
#: **This used to be described as "the check that matters", and it was not.** A
#: card declares its own licence, so the named threat β€” somebody editing a card
#: β€” is exactly the case a self-declaration cannot catch: write
#: `"license": "Apache-2.0"` over a path to `yolo11m.pt` and the licence gate
#: waves it through. The fix was to read `runtime` instead. But `runtime` is
#: also a card field, read eleven lines below a paragraph arguing that the card
#: cannot be trusted, so editing `runtime` rather than `license` defeated it in
#: one line and served AGPL weights as `cattle_identity` under Apache-2.0.
#:
#: **What actually binds a licence to an artefact is
#: `app/adapters/fingerprints.py`**, which reads the file's own structure and
#: says which model it holds. `load_card` runs it, `ModelArtefact.effective_runtime`
#: prefers what the bytes say over what the card claims, and every licence
#: decision below keys off that. This frozenset and the table in
#: `app/adapters/licences.py` both still run, because a layered refusal costs
#: nothing and one of the three may be edited wrongly.
#:
#: The adapter stays in the tree for `evaluation/`, which is the evidence behind
#: ADR 0017. It is unreachable from `discover()`, which is the only path that
#: answers a farmer's request.
DISALLOWED_RUNTIMES = frozenset({"ultralytics"})
@dataclass(frozen=True)
class ModelArtefact:
model_id: str
version: str
capability_key: str
source: str
license: str
sha256: str
validation_notes: str
geography_validated: list[str]
path: Path
#: Which adapter reads this file, **as the card claims**. Optional on the
#: card because governance cares about provenance and the runtime is an
#: implementation detail β€” but a card without it cannot be run, only
#: inspected. Read `effective_runtime` for the answer that is not a claim.
runtime: str | None = None
#: What the artefact's own bytes say it is. `None` when `load_card` was
#: given no file to read, which happens only in tests.
identity: fingerprints.Identification | None = None
#: How an embedding backbone's input is prepared, **read off the card rather
#: than hard-coded at the call site**.
#:
#: `backbones.preprocess` writes out torchvision's eval transform, and its
#: own docstring says getting it wrong "does not raise β€” it quietly returns
#: worse vectors". A resize or a normalisation constant that disagrees with
#: the export produces vectors of the right width and the wrong meaning,
#: which is the same silent failure `IdentityIndex` pins the backbone id to
#: catch. So the numbers travel with the artefact they belong to.
#:
#: `None` for every artefact that is not an embedding backbone β€” YOLOX cards
#: carry none of these β€” and `OnnxEmbeddingAdapter` keeps its own defaults for
#: that case. `load()` shape-checks the result against the graph either way,
#: so a card that disagrees with its own `.onnx` fails loudly.
input_size: int | None = None
image_mean: tuple[float, float, float] | None = None
image_std: tuple[float, float, float] | None = None
embedding_dimensions: int | None = None
@property
def observed_runtime(self) -> str | None:
"""The runtime the bytes identify, or `None` if they identify none."""
return self.identity.runtime if self.identity else None
@property
def refuted_by(self) -> list[str]:
"""Which properties of its own claimed runtime this file fails.
Empty unless the card names a runtime that **has** a fingerprint of the
kind the file was read as, and the bytes fail it. See
`fingerprints.Identification.refutes` for why that case had to be split
out of "unidentified".
"""
if self.identity is None:
return []
return self.identity.refutes(self.runtime)
@property
def effective_runtime(self) -> str | None:
"""Which loader really runs, preferring evidence over assertion.
**This is the field every licence decision should read.** The card's
`runtime` is a claim by whoever wrote the card; the fingerprint is a
property of the bytes. When they disagree the bytes win, because the
bytes are what onnxruntime will load and what a farmer's result will
come out of.
Falls back to the card when nothing recognised the file. That is not a
weakening β€” an unfingerprinted artefact's identity rested on its card
before this existed and still does, and `identity_conflict` reports the
difference between *"checked and agreed"* and *"not checked"*.
**It does not fall back to a claim the bytes refute.** That fallback was
the hole: `observed_runtime or self.runtime` treats *"no fingerprint
matched"* as *"nothing is known"*, so an Ultralytics export with its
initializers renamed inherited its card's `yolox-onnx` and every licence
decision below was answered about YOLOX. `None` here is what makes
`licences.gate` refuse it, under both policies, as an integrity failure
rather than a licence one β€” which is the distinction that module's own
docstring draws and the reason the founder's "drop nothing for a licence
right now" instruction does not reach this case.
"""
if self.observed_runtime:
return self.observed_runtime
return None if self.refuted_by else self.runtime
@property
def identity_conflict(self) -> str:
"""Why the bytes and the card disagree, or an empty string.
Empty covers two different states on purpose β€” agreement, and nothing
to compare β€” because a caller is deciding whether to refuse and both
answers are "do not refuse on this". `identity.detail` is where the
difference is legible.
There are two ways to disagree and they read differently. Either the
bytes matched some *other* fingerprint, and the report can name what the
file actually is; or nothing matched at all but the card's own claim was
testable and failed, and the report can only say what the file is not.
The second is still a conflict, and it is the one that was missing.
**Naming what the file is comes first**, because it is the stronger
statement and the one a licence can be attached to. Only when nothing
matched does the report fall back to saying what was disproved.
"""
observed = self.observed_runtime
if observed is not None and self.runtime and observed != self.runtime:
declared_licence = licences.RUNTIME_LICENCES.get(self.runtime)
real_licence = licences.RUNTIME_LICENCES.get(observed)
return (
f"The card says runtime {self.runtime!r} "
f"({declared_licence.licence if declared_licence else 'unrecorded'}), "
f"but {self.path.name} is structurally {observed!r} "
f"({real_licence.licence if real_licence else 'unrecorded'}). "
f"{self.identity.detail if self.identity else ''} A sha256 proves "
f"these are the reviewed bytes; it does not prove what the bytes "
f"are, and this does."
)
refuted = self.refuted_by
if observed is None and refuted:
declared_licence = licences.RUNTIME_LICENCES.get(self.runtime or "")
return (
f"The card says runtime {self.runtime!r} "
f"({declared_licence.licence if declared_licence else 'unrecorded'}), "
f"and {self.path.name} fails {len(refuted)} of that runtime's "
f"structural properties: {'; '.join(refuted)}. No other "
f"fingerprint matched either, so what this file *is* is unknown "
f"β€” but the card's claim about it has been disproved, and an "
f"artefact nobody can identify may not be served under a licence "
f"borrowed from a model it is not."
)
return ""
@property
def is_copyleft(self) -> bool:
"""Whether the card's *declared* licence is one Animap may not serve.
A self-declaration, and therefore not a control on its own β€” see
`effective_runtime` for the answer that does not depend on the card
telling the truth.
"""
return self.license in DISALLOWED_LICENSES
@property
def uses_disallowed_runtime(self) -> bool:
"""Keyed to the observed runtime, so editing the card does not clear it.
This is the check ADR 0017 added and the one the original exploit was
aimed at. Reading `effective_runtime` rather than `runtime` is what
makes it hold: a card claiming `dinov3-onnx` over `yolo11m.pt` is
`ultralytics` here, because the file's pickle names
`ultralytics.nn.tasks.DetectionModel` and nothing else can load it.
"""
return (self.effective_runtime or "") in DISALLOWED_RUNTIMES
@property
def is_validated(self) -> bool:
"""Validation notes must say something.
An empty string is the tell that a card was filled in to get past the
loader rather than because anyone checked the model.
"""
return bool(self.validation_notes.strip())
def load_card(card_path: Path) -> ModelArtefact:
card = json.loads(card_path.read_text())
missing = [f for f in REQUIRED_CARD_FIELDS if not card.get(f)]
if missing:
raise ArtefactError(
f"{card_path.name} is missing required fields: {', '.join(missing)}. "
f"An artefact that cannot account for itself is not usable."
)
artefact_path = (card_path.parent / card.get("artefact", "")).resolve()
# Cards may point sideways β€” several capabilities share one detector, and
# storing the file twice would mean two things to keep in step. They may not
# point *out*, because a card is a reviewed artefact reference and not a way
# to load whatever is on the box.
root = card_path.parent.parent.resolve()
if not artefact_path.is_relative_to(root):
raise ArtefactError(
f"{card_path.name} names an artefact outside {root.name}/."
)
if not artefact_path.is_file():
raise ArtefactError(f"{card_path.name} names an artefact that is not present.")
digest = _digest(artefact_path)
if digest != card["sha256"]:
raise ArtefactError(
f"{artefact_path.name} does not match the checksum on its card. "
f"Expected {card['sha256'][:12]}…, found {digest[:12]}…"
)
# Read *after* the checksum, because there is no point asking what a file is
# when it is not the file that was reviewed. A structure this does not
# recognise is recorded as unidentified and is not an error β€” see
# `fingerprints.identify`.
identity = fingerprints.identify(artefact_path)
return ModelArtefact(
model_id=card["model_id"],
version=card["version"],
capability_key=card["capability_key"],
source=card["source"],
license=card["license"],
sha256=card["sha256"],
validation_notes=card["validation_notes"],
geography_validated=list(card["geography_validated"]),
path=artefact_path,
runtime=card.get("runtime"),
identity=identity,
input_size=card.get("input_size"),
image_mean=_triple(card.get("image_mean")),
image_std=_triple(card.get("image_std")),
embedding_dimensions=card.get("embedding_dimensions"),
)
def _triple(value) -> tuple[float, float, float] | None:
"""A three-channel normalisation constant, or `None`.
Refuses a malformed one rather than passing it through, because a mean of
the wrong length reaches `backbones.preprocess` as a numpy broadcast error
at request time β€” out of a farmer's capture β€” instead of at start-up where
the card is being read.
"""
if value is None:
return None
if not isinstance(value, (list, tuple)) or len(value) != 3:
raise ArtefactError(
f"An image_mean or image_std must be three numbers, one per "
f"channel, and this card carries {value!r}."
)
return tuple(float(v) for v in value)
def _digest(path: Path) -> str:
sha = hashlib.sha256()
with path.open("rb") as handle:
while chunk := handle.read(_DIGEST_CHUNK):
sha.update(chunk)
return sha.hexdigest()
def _enforcing() -> bool:
"""Whether a licence problem stops a load right now.
Every licence refusal in `discover()` routes through this, so the posture is
one setting β€” `ANIMAP_LICENCE_POLICY`, defined in
`app/adapters/licence_policy.py` β€” and not three independent checks that
have to be found and changed separately.
"""
return licence_policy.current() is licence_policy.LicencePolicy.ENFORCE
def _true_licence(artefact: ModelArtefact) -> str:
"""The licence to write in the ledger, which is not always the card's.
When the bytes identify a runtime, the runtime table's licence is the fact
and the card's `license` is a claim about it. Recording the claim would put
`DINOv3 License` beside a runtime of `ultralytics`, which reads as a
contradiction a person has to unpick rather than as a finding.
"""
real = licences.RUNTIME_LICENCES.get(artefact.effective_runtime or "")
return real.licence if real else artefact.license
def _record_identity(artefact: ModelArtefact, what: str) -> bool:
"""Handle a disagreement between an artefact's bytes and its card.
Returns True when the load should stop. Under the default policy it never
does β€” the founder's instruction is that nothing is dropped on a governance
question right now β€” and what the caller gets instead is a ledger entry
naming the licence the *bytes* arrive under.
**That substitution is the point of the whole check.** Before it, a card
editing `runtime` from `ultralytics` to `dinov3-onnx` produced a load that
was recorded, if it was recorded at all, as a bespoke Meta licence. It is
AGPL-3.0. A ledger that answers *"what did we ship that we should not
have?"* with the wrong licence is worse than no ledger, because somebody
will believe it.
"""
conflict = artefact.identity_conflict
if not conflict:
if artefact.identity is not None and not artefact.identity.identified:
# Not a failure and not a pass. Logged at INFO because a listing of
# every unfingerprinted model is a to-do list, not an incident.
logger.info(
"%s: %s", what,
artefact.identity.detail,
)
return False
if artefact.observed_runtime is None and artefact.refuted_by:
# **Stops the load under both identity policies, and that is not the
# founder's "drop nothing" instruction being ignored.** `record` mode
# exists so a governance exception stays findable afterwards, and its
# whole value is that the entry it writes is true. Here nothing matched,
# so there is no licence to name: the only honest record would say
# "loaded something we cannot identify under terms we cannot name",
# which is not an exception anyone can act on. Refusing costs one
# capability and a log line that says exactly what to fix.
logger.error("Refusing %s: %s", what, conflict)
return True
real = licences.RUNTIME_LICENCES.get(artefact.observed_runtime or "")
refuse = (
licence_policy.identity_policy() is licence_policy.IdentityPolicy.REFUSE
)
if refuse:
logger.error("Refusing %s: %s", what, conflict)
return True
licence_policy.record_exception(
what=what,
# Recorded under what the file *is*, never under what it claimed.
runtime=artefact.observed_runtime or "",
licence=real.licence if real else "UNRECORDED",
reason=conflict,
source_url=real.source_url if real else "",
)
return False
def discover() -> dict[str, ModelArtefact]:
"""Every validated artefact currently present, keyed by capability.
A card that fails its checks is logged and skipped rather than raised β€” one
bad artefact must not take the whole service down, and the capability it
belongs to simply stays unavailable, which is the honest outcome.
**Every licence decision below reads `effective_runtime`**, which prefers
what the bytes are over what the card says they are. That is the difference
between this and every previous version of the gate.
"""
found: dict[str, ModelArtefact] = {}
if not MODELS_DIR.is_dir():
return found
for card_path in sorted(MODELS_DIR.glob("*/model_card.json")):
try:
artefact = load_card(card_path)
except (ArtefactError, json.JSONDecodeError) as exc:
logger.error("Ignoring %s: %s", card_path.parent.name, exc)
continue
if not artefact.is_validated:
logger.error(
"Ignoring %s: validation_notes is empty, so nothing attests that "
"this model works.", card_path.parent.name,
)
continue
if _record_identity(
artefact, f"{card_path.parent.name}/{artefact.model_id}"
):
continue
if artefact.is_copyleft:
# Refused under the default policy, recorded under `record`. The
# capability going unavailable is noticed; a log line on a Tuesday
# is not (ADR 0017) β€” which is why `record` mode logs at ERROR and
# keeps the fact rather than merely warning.
message = (
f"{artefact.model_id} declares licence {artefact.license}, "
f"which Animap may not serve from."
)
if _enforcing():
logger.error(
"Refusing %s: %s Move the capability to a permissively "
"licensed artefact.", card_path.parent.name, message,
)
continue
licence_policy.record_exception(
what=f"{card_path.parent.name}/{artefact.model_id}",
runtime=artefact.effective_runtime or "", licence=artefact.license,
reason=message,
)
if artefact.uses_disallowed_runtime and _enforcing():
# The check the licence field cannot make, keyed to what the bytes
# are. A card that declares Apache-2.0 over a path to an AGPL
# checkpoint passes the test above; a card that also rewrites
# `runtime` used to pass this one, and now does not, because
# `uses_disallowed_runtime` reads `effective_runtime`.
logger.error(
"Refusing %s: runtime %r may not be served, whatever the card "
"declares about the licence (ADR 0017). It exists for "
"evaluation/ only.",
card_path.parent.name, artefact.effective_runtime,
)
continue
if artefact.uses_disallowed_runtime:
licence_policy.record_exception(
what=f"{card_path.parent.name}/{artefact.model_id}",
runtime=artefact.effective_runtime or "",
licence=_true_licence(artefact),
reason="Runtime is on the hard-coded refusal list (ADR 0017).",
)
# The same idea, generalised and applied to every runtime rather than to
# one name. `licences.gate` refuses three things the checks above do not:
# a runtime nobody has recorded a licence for, a runtime whose real
# licence is one Animap may not serve, and β€” the new one β€” a card whose
# declared licence *disagrees* with what that runtime actually loads.
#
# It is handed `effective_runtime`, so on a mislabelled artefact it is
# asked about the model that is really there. That also means the
# card-versus-runtime disagreement it raises on becomes a three-way
# check: the card's licence, the card's runtime, and the bytes.
try:
licences.gate(
artefact.effective_runtime, artefact.license,
what=f"{card_path.parent.name}/{artefact.model_id}",
)
except licences.LicenceRefused as refusal:
logger.error("Refusing %s: %s", card_path.parent.name, refusal)
continue
found[artefact.capability_key] = artefact
return found
class InferenceProvider:
"""Runs a capability, or explains why it cannot.
`run` is deliberately absent from the base class. A provider with a default
implementation would be a provider that can return something without a model,
and that is the one thing this service must not do.
"""
def can_run(self, capability: Capability) -> bool:
raise NotImplementedError
class LocalArtefactProvider(InferenceProvider):
"""Serves capabilities backed by a validated artefact on disk.
Two capabilities have one as of ADR 0018 β€” `cattle_detection` and
`poultry_count`, both YOLOX-m. The other nineteen have none, and `can_run`
returning False for them is the correct state rather than a gap.
"""
def __init__(self, artefacts: dict[str, ModelArtefact] | None = None):
self.artefacts = artefacts if artefacts is not None else discover()
def can_run(self, capability: Capability) -> bool:
artefact = self.artefacts.get(capability.key)
return artefact is not None and artefact.is_validated and capability.is_runnable
def artefact_for(self, capability: Capability) -> ModelArtefact | None:
return self.artefacts.get(capability.key)
class HostedModelProvider(InferenceProvider):
"""Serves capabilities whose model is somebody else's, reached over HTTP.
## Why this is a provider and not a special case in `LocalArtefactProvider`
`can_run` there asks one question β€” is there a validated file on disk β€” and
for these sixteen the answer is permanently no, and correctly so. There is
no artefact to checksum because there are no bytes: directive Β§4 asks for a
hosted visual reasoner precisely where no public dataset exists to train
one. Bending the local provider to answer True without a file would make
`is_validated` mean two different things depending on the capability, and
that check is the thing standing between a card somebody filled in and a
model somebody validated.
So the two providers answer for disjoint sets and `main` asks both.
## The artefact this returns, and why it names the live model
`InferenceResult` requires `model_id` and `model_version`, and the API
refuses a result whose pair does not equal the run's β€” *"register a release
for the artefact that actually ran"*. A hosted model has no sha256, so what
identifies it is its name, and that is read **at call time** from
`ANIMAP_MULTIMODAL_MODEL` rather than frozen at import.
That has a consequence worth stating plainly: **rotating the model refuses
every queued run of these capabilities until a release is registered for the
new one.** That is the correct behaviour and not a hazard. A different model
is different evidence, `evidence_correction` is a training set built on
knowing which model said what, and a silent swap would put two models'
answers in one column. Re-registering is a management command β€” neither a
rebuild nor a redeploy β€” so a rotation still costs a restart and a
`register_capability_release`, exactly as `multimodal.py` intends.
`capability.model_provider` stays the stable string `hosted-multimodal`. It
is what makes `is_runnable` true and what the Android app copies out of
`capabilities.json`, and it must not change when a model does β€” a device
would otherwise need a rebuild to keep a capability visible.
"""
#: What `sha256` says when there are no bytes to hash.
#:
#: Not an empty string and not a fake digest. A reader of a stored result
#: asking "which file was this" gets an answer that says there was no file,
#: rather than a blank that reads as a missing field or sixty-four zeroes
#: that read as a checksum.
NO_LOCAL_BYTES = "hosted:no-local-artefact"
def __init__(self, keys: frozenset[str] | None = None):
# Imported here rather than at module scope: `reasoning` imports
# `providers` for `ModelArtefact`, and the pair would not load.
if keys is None:
from app.reasoning import RUBRICS
keys = frozenset(RUBRICS)
self.keys = keys
def _configured_model(self) -> str | None:
provider = os.environ.get("ANIMAP_MULTIMODAL_PROVIDER", "").strip()
model = os.environ.get("ANIMAP_MULTIMODAL_MODEL", "").strip()
if not provider or not model:
return None
return f"{provider}/{model}"
def can_run(self, capability: Capability) -> bool:
"""True when this capability is hosted, declared runnable, and reachable.
The third clause is the one that keeps `/health` honest. Without a key
the adapter is not ready and every one of these answers `unavailable`
with a reason, which is a deployment that says what it is missing rather
than one that fails sixteen jobs to find out.
"""
from app.adapters.multimodal import HostedMultimodalAdapter
from app.adapters.transports import transport_from_env
from app.reasoning import HOSTED_PROVIDER
if capability.key not in self.keys:
return False
if capability.model_provider != HOSTED_PROVIDER:
return False
if not capability.is_runnable:
return False
if self._configured_model() is None:
return False
# Built the way `adapters/registry.py` builds it, so `/health`'s
# listing and this answer cannot disagree about whether the vendor is
# reachable. A bare adapter has no transport and is never ready.
return HostedMultimodalAdapter(
transport=transport_from_env()
).availability().ready
def artefact_for(self, capability: Capability) -> ModelArtefact | None:
model = self._configured_model()
if capability.key not in self.keys or model is None:
return None
return ModelArtefact(
model_id=model,
# The rubric's version, not the vendor's. A materially changed
# question is different evidence from the same model, and this is
# what a release row records it as. Bumped by hand in `reasoning`.
version="1",
capability_key=capability.key,
source="hosted",
license="vendor-terms",
sha256=self.NO_LOCAL_BYTES,
validation_notes=(
"No local artefact and no measurement. This capability is "
"answered by a hosted visual reasoner, is `experimental`, and "
"requires human confirmation β€” see app/reasoning.py."
),
geography_validated=[UNVALIDATED_GEOGRAPHY],
path=Path("hosted"),
runtime="http",
)
class CompositeProvider(InferenceProvider):
"""Every provider, asked in order, first answer wins.
The two sets are disjoint β€” one is capabilities with a file, the other is
capabilities that can never have one β€” so "first answer wins" resolves
nothing today and is a rule rather than a tiebreak. `_OVERLAP` in `main`
refuses a collision on the runner side for the same reason.
"""
def __init__(self, *providers: InferenceProvider):
self.providers = providers
@property
def artefacts(self) -> dict[str, ModelArtefact]:
"""Only the ones on disk. `/health` reports this as `artefacts_loaded`,
and a hosted model is not an artefact anybody loaded."""
merged: dict[str, ModelArtefact] = {}
for provider in self.providers:
merged.update(getattr(provider, "artefacts", {}))
return merged
def can_run(self, capability: Capability) -> bool:
return any(p.can_run(capability) for p in self.providers)
def artefact_for(self, capability: Capability) -> ModelArtefact | None:
for provider in self.providers:
if provider.can_run(capability):
return provider.artefact_for(capability)
for provider in self.providers:
found = provider.artefact_for(capability)
if found is not None:
return found
return None