154 lines
7.1 KiB
Python
154 lines
7.1 KiB
Python
"""Model-provider plugins under ``plugins.isolation: host``.
|
|
|
|
Model-provider discovery runs while ``hermes_cli.config`` / ``hermes_cli.auth`` are still importing,
|
|
so it must not start the plugin host (building the host's environment imports those same modules).
|
|
Profile DATA therefore comes from a one-shot, credential-free extraction
|
|
(``plugin_host_child --extract-profiles``), cached under the profile home by the plugin's file
|
|
fingerprint. Overridden methods and callable fields run later in the profile's plugin host, addressed
|
|
by (plugin dir, profile name, attribute) so they survive host restarts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import inspect
|
|
import json
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict, List
|
|
|
|
from hermes_cli.plugin_host_wire import PluginHostUnavailable, decode, signature_from
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_EXTRACT_TIMEOUT_SECS = 60.0
|
|
_CACHE_VERSION = 1
|
|
|
|
|
|
def load_hosted_profiles(plugin_dir: Path, module_name: str) -> list[Any]:
|
|
"""ProviderProfile proxies for a model-provider plugin whose code runs in the plugin host."""
|
|
from providers.base import ProviderProfile
|
|
payload = _cached_extraction(Path(plugin_dir), module_name)
|
|
if payload.get("error"):
|
|
raise PluginHostUnavailable(str(payload["error"]))
|
|
return [_profile_proxy(ProviderProfile, str(plugin_dir), module_name, entry)
|
|
for entry in payload.get("profiles") or []]
|
|
|
|
|
|
def _fingerprint(plugin_dir: Path) -> str:
|
|
digest = hashlib.sha256(f"{_CACHE_VERSION}|{sys.executable}".encode())
|
|
try:
|
|
from hermes_cli import __version__
|
|
digest.update(str(__version__).encode())
|
|
except Exception:
|
|
pass
|
|
for path in sorted(plugin_dir.rglob("*")):
|
|
if not path.is_file() or "__pycache__" in path.parts or ".git" in path.parts:
|
|
continue
|
|
stat = path.stat()
|
|
digest.update(f"{path.relative_to(plugin_dir)}|{stat.st_size}|{stat.st_mtime_ns}".encode())
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _cached_extraction(plugin_dir: Path, module_name: str) -> dict[str, Any]:
|
|
from hermes_constants import get_hermes_home
|
|
# Keyed by the full path too: a user and a project plugin may share a directory name.
|
|
path_key = hashlib.sha256(str(plugin_dir.resolve()).encode("utf-8")).hexdigest()[:12]
|
|
cache = get_hermes_home() / "cache" / "plugin_host" / "model-providers" / f"{plugin_dir.name}-{path_key}.json"
|
|
fingerprint = _fingerprint(plugin_dir)
|
|
try:
|
|
cached = json.loads(cache.read_text(encoding="utf-8-sig"))
|
|
if cached.get("fingerprint") == fingerprint and cached.get("module_name") == module_name:
|
|
return cached["payload"]
|
|
except (OSError, ValueError, KeyError):
|
|
pass
|
|
payload = _extract(plugin_dir, module_name)
|
|
if not payload.get("error"):
|
|
cache.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = cache.with_suffix(f".{os.getpid()}.tmp")
|
|
tmp.write_text(json.dumps({"fingerprint": fingerprint, "module_name": module_name,
|
|
"payload": payload}), encoding="utf-8")
|
|
os.replace(tmp, cache)
|
|
return payload
|
|
|
|
|
|
def _extract(plugin_dir: Path, module_name: str) -> dict[str, Any]:
|
|
"""Import the plugin in a throwaway host process with no credentials and read its profiles."""
|
|
from hermes_cli.plugin_isolation import HOST_PROCESS_ENV, host_launcher
|
|
from hermes_constants import get_hermes_home
|
|
repo_root = str(Path(__file__).resolve().parents[1])
|
|
env = {key: os.environ[key] for key in ("PATH", "LANG", "LC_ALL", "TZ", "SYSTEMROOT") if key in os.environ}
|
|
# Windows resolves Path.home() from USERPROFILE, never HOME: without it the child cannot start.
|
|
env.update(HOME=str(Path.home()), USERPROFILE=str(Path.home()), HERMES_HOME=str(get_hermes_home()),
|
|
PYTHONPATH=repo_root,
|
|
**{HOST_PROCESS_ENV: "1"})
|
|
argv = [*host_launcher(), sys.executable, "-m", "hermes_cli.plugin_host_child", "--extract-profiles",
|
|
str(plugin_dir), module_name]
|
|
try:
|
|
done = subprocess.run(argv, stdin=subprocess.DEVNULL, capture_output=True, env=env,
|
|
timeout=_EXTRACT_TIMEOUT_SECS, check=False)
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
return {"error": f"profile extraction failed: {exc}"}
|
|
try:
|
|
return json.loads(done.stdout.decode("utf-8"))
|
|
except ValueError:
|
|
tail = done.stderr.decode("utf-8", "replace").strip().splitlines()[-3:]
|
|
return {"error": f"profile extraction exited {done.returncode}: {' | '.join(tail)}"}
|
|
|
|
|
|
def _host_call(plugin_dir: str, module_name: str, profile: str, attr: str) -> Callable[..., Any]:
|
|
def call(*args: Any, **kwargs: Any) -> Any:
|
|
from hermes_cli.plugin_isolation import user_plugin_host
|
|
host = user_plugin_host()
|
|
if host is None:
|
|
raise PluginHostUnavailable("plugins.isolation is no longer 'host'; restart Hermes")
|
|
return host.profile_call(plugin_dir, module_name, profile, attr, args, kwargs)
|
|
return call
|
|
|
|
|
|
def _profile_proxy(base: type, plugin_dir: str, module_name: str, entry: dict[str, Any]) -> Any:
|
|
name = str(entry["name"])
|
|
namespace: dict[str, Any] = {"__module__": __name__,
|
|
"__repr__": lambda self_: f"<plugin-host profile {name!r}>"}
|
|
fields = {key: decode(value) for key, value in (entry.get("fields") or {}).items()}
|
|
for attr, meta in (entry.get("calls") or {}).items():
|
|
call = _host_call(plugin_dir, module_name, name, attr)
|
|
if meta.get("async"):
|
|
sync_call = call
|
|
|
|
async def call(*args: Any, _sync: Callable[..., Any] = sync_call, **kwargs: Any) -> Any:
|
|
return await asyncio.to_thread(_sync, *args, **kwargs)
|
|
signature = signature_from(meta.get("sig"))
|
|
if signature is not None:
|
|
call.__signature__ = signature # type: ignore[attr-defined]
|
|
call.__name__ = call.__qualname__ = attr
|
|
if meta.get("field"):
|
|
fields[attr] = call
|
|
else:
|
|
namespace[attr] = _as_method(call)
|
|
cls = type(f"Hosted{entry.get('type') or base.__name__}", (base,), namespace)
|
|
profile = object.__new__(cls)
|
|
for key, value in fields.items():
|
|
object.__setattr__(profile, key, value)
|
|
return profile
|
|
|
|
|
|
def _as_method(call: Callable[..., Any]) -> Callable[..., Any]:
|
|
if asyncio.iscoroutinefunction(call):
|
|
async def method(self_: Any, *args: Any, **kwargs: Any) -> Any:
|
|
return await call(*args, **kwargs)
|
|
else:
|
|
def method(self_: Any, *args: Any, **kwargs: Any) -> Any:
|
|
return call(*args, **kwargs)
|
|
method.__name__ = method.__qualname__ = call.__name__
|
|
signature = getattr(call, "__signature__", None)
|
|
if signature is not None: # callers introspect accepted kwargs; bound access drops ``self`` again
|
|
self_param = inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
method.__signature__ = signature.replace( # type: ignore[attr-defined]
|
|
parameters=[self_param, *signature.parameters.values()])
|
|
return method
|