from __future__ import annotations
import logging
import os
import re
from pathlib import Path
from typing import Mapping, Sequence
import grpc
from . import auth
from . import model_pb2
from . import model_pb2_grpc
from .client import _get_server_url
from .model_snapshot import (
ModelSnapshotCache,
ModelSnapshotError,
SnapshotSink,
safe_commit_hash,
split_by_weight,
)
logger = logging.getLogger("modelexpress.model_client")
_COMMIT_HASH_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$")
DEFAULT_MAX_MESSAGE_SIZE = 100 * 1024 * 1024
DEFAULT_CHUNK_SIZE = 1024 * 1024
MAX_CHUNK_SIZE = (1 << 32) - 1
class ModelCacheError(RuntimeError):
class ModelCacheClient:
def __init__(
self,
server_url: str | None = None,
cache_directory: str | os.PathLike[str] | None = None,
chunk_size: int | None = None,
max_message_size: int = DEFAULT_MAX_MESSAGE_SIZE,
):
self.server_url = _get_server_url(server_url)
self.cache_directory = cache_directory
self.chunk_size = DEFAULT_CHUNK_SIZE if chunk_size is None else chunk_size
if not 0 < self.chunk_size <= MAX_CHUNK_SIZE:
raise ValueError(f"chunk_size must be between 1 and {MAX_CHUNK_SIZE} bytes")
if max_message_size <= 0:
raise ValueError("max_message_size must be positive")
self._max_message_size = max_message_size
self._channel: grpc.Channel | None = None
self._stub: model_pb2_grpc.ModelServiceStub | None = None
def __enter__(self) -> "ModelCacheClient":
return self
def __exit__(self, *exc_info) -> None:
self.close()
@property
def stub(self) -> model_pb2_grpc.ModelServiceStub:
if self._stub is None:
options = [
("grpc.max_send_message_length", self._max_message_size),
("grpc.max_receive_message_length", self._max_message_size),
("grpc.keepalive_time_ms", 30_000),
("grpc.keepalive_timeout_ms", 10_000),
("grpc.keepalive_permit_without_calls", 1),
("grpc.http2.max_pings_without_data", 0),
]
self._channel = auth.with_auth(
grpc.insecure_channel(self.server_url, options=options)
)
self._stub = model_pb2_grpc.ModelServiceStub(self._channel)
logger.debug("ModelCacheClient connected to %s", self.server_url)
return self._stub
def close(self) -> None:
if self._channel is not None:
self._channel.close()
self._channel = None
self._stub = None
def ensure_downloaded(
self,
model_name: str,
provider: int = model_pb2.HUGGING_FACE,
ignore_weights: bool = False,
revision: str | None = None,
) -> str | None:
request = model_pb2.ModelDownloadRequest(
model_name=model_name,
provider=provider,
ignore_weights=ignore_weights,
)
if revision is not None:
request.revision = revision
for update in self.stub.EnsureModelDownloaded(request):
if update.message:
logger.info("Model %s: %s", model_name, update.message)
if update.status == model_pb2.DOWNLOADED:
return _reported_revision(model_name, update)
if update.status == model_pb2.ERROR:
raise ModelCacheError(
f"ModelExpress failed to download {model_name}: "
f"{update.message or 'unknown server error'}"
)
if update.status != model_pb2.DOWNLOADING:
raise ModelCacheError(
f"ModelExpress reported unknown status {update.status} for {model_name}"
)
raise ModelCacheError(
f"ModelExpress status stream ended before {model_name} was downloaded"
)
def list_files(
self,
model_name: str,
provider: int = model_pb2.HUGGING_FACE,
ignore_weights: bool = False,
revision: str | None = None,
) -> dict[str, int]:
request = model_pb2.ModelFilesRequest(
model_name=model_name,
provider=provider,
ignore_weights=ignore_weights,
)
if revision is not None:
request.revision = revision
response = self.stub.ListModelFiles(request)
return _manifest_to_dict(response)
def install_metadata_snapshot(
self,
model_name: str,
provider: int = model_pb2.HUGGING_FACE,
requested_revision: str | None = None,
) -> Path:
revision = self.ensure_downloaded(
model_name, provider, ignore_weights=True, revision=requested_revision
)
if requested_revision is not None:
if revision is None:
raise ModelCacheError(
f"ModelExpress did not confirm revision {requested_revision!r} for "
f"{model_name}; the server may predate pinned-revision support, and "
"installing its default revision would not be what was asked for"
)
if (
_COMMIT_HASH_PATTERN.match(requested_revision)
and requested_revision.lower() != revision.lower()
):
raise ModelCacheError(
f"ModelExpress resolved {requested_revision!r} to commit {revision} "
f"for {model_name}; a commit hash cannot resolve to another commit"
)
manifest = self.list_files(
model_name, provider, ignore_weights=True, revision=revision
)
metadata_paths, _ = split_by_weight(manifest.keys())
if not metadata_paths:
raise ModelCacheError(
f"ModelExpress returned no non-weight files for {model_name}"
)
expected = {path: manifest[path] for path in metadata_paths}
cache = ModelSnapshotCache(model_name, self.cache_directory)
with cache.lock():
if requested_revision is not None:
existing = cache.resolve_pinned_snapshot(expected, revision)
else:
existing = cache.resolve_snapshot(expected, revision)
if existing is not None:
cache.write_revision_ref(revision, requested_revision)
logger.info("Reusing local snapshot for %s at %s", model_name, existing)
return existing
staging = cache.staging()
try:
commit_hash = self._stream_into(
model_name,
provider,
metadata_paths,
expected,
staging,
expected_commit=revision,
revision=revision,
)
snapshot_path = staging.publish(
commit_hash, expected, requested_revision=requested_revision
)
except BaseException:
staging.discard()
raise
logger.info(
"Installed %d metadata files for %s at %s",
len(expected),
model_name,
snapshot_path,
)
return snapshot_path
def install_weight_files(
self,
model_name: str,
snapshot_path: Path,
provider: int = model_pb2.HUGGING_FACE,
) -> None:
try:
revision = self.ensure_downloaded(
model_name, provider, revision=snapshot_path.name
)
except grpc.RpcError as exc:
logger.warning(
"Pinned download of %s at commit %s failed (%s); retrying unpinned",
model_name,
snapshot_path.name,
exc,
)
revision = self.ensure_downloaded(model_name, provider)
if revision is not None and revision != snapshot_path.name:
raise ModelCacheError(
f"Server resolved {model_name} to commit {revision} but the local "
f"snapshot is {snapshot_path.name}; refusing to mix revisions"
)
manifest = self.list_files(model_name, provider, revision=revision)
_, weight_paths = split_by_weight(manifest.keys())
if not weight_paths:
raise ModelCacheError(
f"ModelExpress returned no weight files for {model_name}"
)
expected = {path: manifest[path] for path in weight_paths}
cache = ModelSnapshotCache(model_name, self.cache_directory)
with cache.lock():
if cache.has_files(snapshot_path, expected):
logger.info("Weights for %s already present at %s", model_name, snapshot_path)
return
patch = cache.patch(snapshot_path)
try:
self._stream_into(
model_name,
provider,
weight_paths,
expected,
patch,
expected_commit=snapshot_path.name,
revision=revision,
)
patch.commit()
except BaseException:
patch.rollback()
raise
finally:
patch.close()
logger.info(
"Installed %d weight files for %s at %s",
len(expected),
model_name,
snapshot_path,
)
def _stream_into(
self,
model_name: str,
provider: int,
paths: Sequence[str],
expected: Mapping[str, int],
sink: SnapshotSink,
expected_commit: str | None = None,
revision: str | None = None,
) -> str:
request = model_pb2.ModelFilesRequest(
model_name=model_name,
provider=provider,
chunk_size=self.chunk_size,
file_selector=model_pb2.ModelFileSelector(paths=list(paths)),
)
if revision is not None:
request.revision = revision
commit_hash: str | None = None
received: dict[str, int] = {}
current_path: str | None = None
current_size = 0
current_total = 0
saw_final_marker = False
for chunk in self.stub.StreamModelFiles(request):
if saw_final_marker:
raise ModelCacheError("Server sent data after the final stream marker")
if commit_hash is None:
if not chunk.HasField("commit_hash"):
raise ModelCacheError("First file chunk did not carry a commit hash")
commit_hash = _require_commit_hash(chunk.commit_hash)
if expected_commit is not None and commit_hash != expected_commit:
raise ModelCacheError(
f"Server streamed commit {commit_hash} but the local snapshot "
f"is {expected_commit}; refusing to mix revisions"
)
elif chunk.HasField("commit_hash") and chunk.commit_hash != commit_hash:
raise ModelCacheError("Server changed the commit hash mid-stream")
relative_path = chunk.relative_path
if relative_path not in expected:
raise ModelCacheError(f"Server streamed an unrequested file: {relative_path!r}")
if current_path != relative_path:
if current_path is not None:
raise ModelCacheError(
f"Server started {relative_path!r} before {current_path!r} finished"
)
if relative_path in received:
raise ModelCacheError(f"Server streamed {relative_path!r} twice")
if chunk.offset != 0:
raise ModelCacheError(
f"First chunk of {relative_path!r} has offset {chunk.offset}"
)
if chunk.total_size != expected[relative_path]:
raise ModelCacheError(
f"Size mismatch for {relative_path!r}: manifest "
f"{expected[relative_path]}, stream {chunk.total_size}"
)
sink.begin_file(relative_path)
current_path = relative_path
current_size = 0
current_total = chunk.total_size
elif chunk.total_size != current_total:
raise ModelCacheError(f"Server changed the size of {relative_path!r} mid-file")
if chunk.offset != current_size:
raise ModelCacheError(
f"Unexpected offset {chunk.offset} for {relative_path!r}, "
f"expected {current_size}"
)
if current_size + len(chunk.data) > current_total:
raise ModelCacheError(f"Data for {relative_path!r} exceeds its advertised size")
if chunk.is_last_file and not chunk.is_last_chunk:
raise ModelCacheError("Final-file marker set before the file's final chunk")
sink.write(chunk.data)
current_size += len(chunk.data)
if chunk.is_last_chunk:
if current_size != current_total:
raise ModelCacheError(
f"Incomplete file {relative_path!r}: received {current_size}, "
f"expected {current_total}"
)
sink.end_file()
received[relative_path] = current_size
current_path = None
saw_final_marker = chunk.is_last_file
elif current_size == current_total:
raise ModelCacheError(f"File {relative_path!r} completed without a final chunk")
if commit_hash is None:
raise ModelCacheError("Server streamed no model files")
if not saw_final_marker:
raise ModelCacheError("Stream ended before the final file marker")
if received != dict(expected):
missing = sorted(set(expected) - set(received))
raise ModelCacheError(f"Stream did not match the manifest; missing files: {missing}")
return commit_hash
def _manifest_to_dict(manifest: model_pb2.ModelFileList) -> dict[str, int]:
files: dict[str, int] = {}
for file_info in manifest.files:
relative_path = file_info.relative_path
if relative_path in files:
raise ModelCacheError(f"Server returned duplicate file path: {relative_path!r}")
files[relative_path] = file_info.size
if not files:
raise ModelCacheError("Server returned an empty model file manifest")
total = sum(files.values())
if total != manifest.total_size:
raise ModelCacheError(
f"Manifest total mismatch: files add up to {total} bytes, "
f"manifest advertises {manifest.total_size} bytes"
)
return files
def _reported_revision(model_name: str, update: model_pb2.ModelStatusUpdate) -> str | None:
if not update.HasField("resolved_revision"):
return None
try:
return safe_commit_hash(update.resolved_revision)
except ModelSnapshotError:
logger.warning(
"ModelExpress reported an unusable revision %r for %s; ignoring it",
update.resolved_revision,
model_name,
)
return None
def _require_commit_hash(commit_hash: str) -> str:
try:
return safe_commit_hash(commit_hash)
except ModelSnapshotError as exc:
raise ModelCacheError(str(exc)) from exc