hugpy-engine 0.3.0__tar.gz → 0.3.1__tar.gz

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 (42) hide show
  1. {hugpy_engine-0.3.0 → hugpy_engine-0.3.1}/PKG-INFO +1 -1
  2. {hugpy_engine-0.3.0 → hugpy_engine-0.3.1}/pyproject.toml +1 -1
  3. hugpy_engine-0.3.1/src/hugpy_engine/__init__.py +49 -0
  4. hugpy_engine-0.3.1/src/hugpy_engine/categories.py +132 -0
  5. hugpy_engine-0.3.1/src/hugpy_engine/config/main.py +84 -0
  6. hugpy_engine-0.3.1/src/hugpy_engine/config/models/models_config.py +190 -0
  7. hugpy_engine-0.3.1/src/hugpy_engine/config/models/models_default.py +150 -0
  8. hugpy_engine-0.3.1/src/hugpy_engine/dispatch/__init__.py +36 -0
  9. hugpy_engine-0.3.1/src/hugpy_engine/dispatch/dispatch.py +1237 -0
  10. hugpy_engine-0.3.1/src/hugpy_engine/fit/__init__.py +0 -0
  11. hugpy_engine-0.3.1/src/hugpy_engine/fit/types.py +458 -0
  12. hugpy_engine-0.3.1/src/hugpy_engine/llama/__init__.py +0 -0
  13. hugpy_engine-0.3.1/src/hugpy_engine/llama/runners/__init__.py +0 -0
  14. hugpy_engine-0.3.1/src/hugpy_engine/llama/runners/fitevict_runner.py +197 -0
  15. hugpy_engine-0.3.1/src/hugpy_engine/name_match.py +287 -0
  16. hugpy_engine-0.3.1/src/hugpy_engine/placement.py +471 -0
  17. hugpy_engine-0.3.1/src/hugpy_engine/py.typed +0 -0
  18. hugpy_engine-0.3.1/src/hugpy_engine/resolvers/__init__.py +22 -0
  19. hugpy_engine-0.3.1/src/hugpy_engine/resolvers/assure_model_key.py +618 -0
  20. hugpy_engine-0.3.1/src/hugpy_engine/resolvers/categories/__init__.py +22 -0
  21. hugpy_engine-0.3.1/src/hugpy_engine/resolvers/categories/builders.py +173 -0
  22. hugpy_engine-0.3.1/src/hugpy_engine/resolvers/categories/frameworks.py +75 -0
  23. hugpy_engine-0.3.1/src/hugpy_engine/resolvers/model_resolver.py +525 -0
  24. hugpy_engine-0.3.1/src/hugpy_engine/schemas/__init__.py +0 -0
  25. hugpy_engine-0.3.1/src/hugpy_engine/schemas/metadata_schemas.py +63 -0
  26. hugpy_engine-0.3.1/src/hugpy_engine/schemas/model_schemas.py +101 -0
  27. hugpy_engine-0.3.1/src/hugpy_engine/schemas/runner_schemas.py +33 -0
  28. hugpy_engine-0.3.1/src/hugpy_engine/tasks.py +349 -0
  29. {hugpy_engine-0.3.0 → hugpy_engine-0.3.1}/src/hugpy_engine.egg-info/PKG-INFO +1 -1
  30. hugpy_engine-0.3.1/src/hugpy_engine.egg-info/SOURCES.txt +38 -0
  31. hugpy_engine-0.3.0/src/hugpy_engine/__init__.py +0 -41
  32. hugpy_engine-0.3.0/src/hugpy_engine.egg-info/SOURCES.txt +0 -13
  33. {hugpy_engine-0.3.0 → hugpy_engine-0.3.1}/README.md +0 -0
  34. {hugpy_engine-0.3.0 → hugpy_engine-0.3.1}/setup.cfg +0 -0
  35. {hugpy_engine-0.3.0/src/hugpy_engine/schemas → hugpy_engine-0.3.1/src/hugpy_engine/config}/__init__.py +0 -0
  36. /hugpy_engine-0.3.0/src/hugpy_engine/py.typed → /hugpy_engine-0.3.1/src/hugpy_engine/config/models/__init__.py +0 -0
  37. {hugpy_engine-0.3.0 → hugpy_engine-0.3.1}/src/hugpy_engine/schemas/chat_schemas.py +0 -0
  38. {hugpy_engine-0.3.0 → hugpy_engine-0.3.1}/src/hugpy_engine/schemas/event_schemas.py +0 -0
  39. {hugpy_engine-0.3.0 → hugpy_engine-0.3.1}/src/hugpy_engine/schemas/task_schemas.py +0 -0
  40. {hugpy_engine-0.3.0 → hugpy_engine-0.3.1}/src/hugpy_engine.egg-info/dependency_links.txt +0 -0
  41. {hugpy_engine-0.3.0 → hugpy_engine-0.3.1}/src/hugpy_engine.egg-info/requires.txt +0 -0
  42. {hugpy_engine-0.3.0 → hugpy_engine-0.3.1}/src/hugpy_engine.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hugpy-engine
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Hugpy engine: model discovery and classification, allocation and eviction, native llama.cpp and GGUF runners, and the prompt-to-reply inference path with task and placement seams
5
5
  Author-email: putkoff <support@hugpy.ai>
6
6
  License-Expression: LicenseRef-Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hugpy-engine"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Hugpy engine: model discovery and classification, allocation and eviction, native llama.cpp and GGUF runners, and the prompt-to-reply inference path with task and placement seams"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,49 @@
1
+ """Hugpy inference engine — the one-call prompt API, revived.
2
+
3
+ ``execute_prompt(*args, **kwargs)`` returns the output you expected for a task:
4
+ positional args are inferred by type (str -> a model id or a prompt, int ->
5
+ max_new_tokens, float -> temperature then top_p, bool -> do_sample, list ->
6
+ messages), the request is resolved to a model + task, and the call is handed to
7
+ the box's fitevict front door through hugpy_core's relay (which picks the
8
+ worker and owns evict-to-fit, load, serving and the call log).
9
+
10
+ Chat/vision run today; a task whose runner has no front-door endpoint yet
11
+ resolves to a clean, named error (fail-as-data).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ try:
16
+ from importlib.metadata import version as _package_version
17
+
18
+ __version__ = _package_version("hugpy-engine")
19
+ except Exception: # pragma: no cover - source tree without metadata
20
+ __version__ = "0.0.0+unknown"
21
+
22
+ from hugpy_engine.dispatch.dispatch import (
23
+ execute_chat_stream,
24
+ execute_prompt,
25
+ execute_prompt_stream,
26
+ infer_arg_name,
27
+ normalize_prompt_kwargs,
28
+ runner_for,
29
+ )
30
+ from hugpy_engine.resolvers import resolve, resolve_model_key
31
+ from hugpy_engine.config.models.models_config import (
32
+ MODEL_REGISTRY,
33
+ get_model_registry,
34
+ refresh_registry,
35
+ )
36
+
37
+ __all__ = [
38
+ "MODEL_REGISTRY",
39
+ "execute_chat_stream",
40
+ "execute_prompt",
41
+ "execute_prompt_stream",
42
+ "get_model_registry",
43
+ "infer_arg_name",
44
+ "normalize_prompt_kwargs",
45
+ "refresh_registry",
46
+ "resolve",
47
+ "resolve_model_key",
48
+ "runner_for",
49
+ ]
@@ -0,0 +1,132 @@
1
+ """Model-catalog domain constants: task vocabulary, servable pairs, defaults.
2
+
3
+ The ``DEFAULT_*_MODEL`` values here are the env-configured defaults from
4
+ ``hugpy_platform.constants``; ``config.models.models_default`` reconciles them
5
+ against the live registry after discovery and patches ``TASK_DEFAULTS`` /
6
+ ``MEDIA_DEFAULTS`` in place.
7
+ """
8
+ from typing import Dict
9
+ from hugpy_platform.constants import (
10
+ DEFAULT_CHAT_MODEL,
11
+ DEFAULT_DEPTH_MODEL,
12
+ DEFAULT_EMBED_MODEL,
13
+ DEFAULT_VISION_MODEL,
14
+ DEFAULT_WHISPER_MODEL,
15
+ DEFAULT_DETECT_MODEL,
16
+ DEFAULT_IMAGEGEN_MODEL,
17
+ DEFAULT_IMG_CLASSIFY_MODEL,
18
+ DEFAULT_KEYWORDS_MODEL,
19
+ DEFAULT_SEGMENT_MODEL,
20
+ DEFAULT_SUMMARIZE_MODEL,
21
+ )
22
+ # pipeline_tag -> task vocabulary. Every value is HF-derivable.
23
+ HF_TASK_TO_TASKS = {
24
+ "text-generation": ["text-generation"],
25
+ "image-text-to-text": ["image-text-to-text", "text-generation"],
26
+ "automatic-speech-recognition": ["automatic-speech-recognition"],
27
+ # SPEECH OUT (chatterbox seat). Its absence here is why a row whose card
28
+ # says ``pipeline_tag: text-to-speech`` fell through to _base_tasks'
29
+ # conservative floor and advertised ["text-generation"] — a voice-cloning
30
+ # model offering itself as a chat model. The pair
31
+ # ("transformers","text-to-speech") is in RUNNER_PAIRS below, so the task is
32
+ # servable, not merely visible.
33
+ "text-to-speech": ["text-to-speech"],
34
+ "text-summarization": ["text-summarization"],
35
+ "text2text-generation": ["text-summarization", "text2text-generation"],
36
+ # KeyBERT rides any sentence-transformers model, so embedding models
37
+ # also serve keyword-extraction.
38
+ "feature-extraction": ["feature-extraction", "sentence-similarity", "keyword-extraction"],
39
+ "sentence-similarity": ["feature-extraction", "sentence-similarity", "keyword-extraction"],
40
+ "text-to-image": ["text-to-image"],
41
+ # VIDEO. A full video pipeline (Wan/LTX/Cog/Hunyuan/Mochi/…) is SERVEABLE:
42
+ # the runners live in hugpy_video's studio registry, and the
43
+ # ("transformers","text-to-video")/("transformers","image-to-video")
44
+ # RUNNER_PAIRS below make derive_model_config_row flag it serveable so it is
45
+ # graded like any other model (no-grader until a video suite exists) instead
46
+ # of the blanket "unservable/pipeline component" it read as before
47
+ # (2026-09-24). Before 2026-07-27 a Wan T2V model fell through _base_tasks'
48
+ # "conservative floor" and advertised ["text-generation"]: a video diffusion
49
+ # model offering itself as a chat model. A single-file GGUF video transformer
50
+ # is NOT a full pipeline — it stays a pipeline-component
51
+ # (models_config._correct_pipeline_component).
52
+ "text-to-video": ["text-to-video"],
53
+ "image-to-video": ["image-to-video"],
54
+ # NOTE: "image-to-image" is intentionally NOT a DISCOVERY pipeline_tag key.
55
+ # A raw HF pipeline_tag=image-to-image (native EDIT models like
56
+ # Qwen-Image-Edit) is still not auto-advertised from that tag alone. Instead,
57
+ # img2img capability is DERIVED at the config layer
58
+ # (models_config._derive_tasks): any image-GENERATION checkpoint — an
59
+ # SD/SDXL/flux-class diffusers model or a comfy SD-lineage checkpoint — serves
60
+ # image-to-image from the SAME weights it serves text-to-image with
61
+ # (AutoPipelineForImage2Image / the comfy image-conditioned graph), so it
62
+ # gains "image-to-image" whenever it advertises "text-to-image". (Operator
63
+ # ruling 2026-07-05: primary_task / pipeline_tag is NOT a definitive capability
64
+ # marker — plenty of models do img2img without saying so.) The
65
+ # ("transformers","image-to-image") / ("comfy","image-to-image") RUNNER_PAIRS
66
+ # entries below back that derivation.
67
+ # Vision-analysis family — HF pipeline tags map 1:1.
68
+ "depth-estimation": ["depth-estimation"],
69
+ "object-detection": ["object-detection"],
70
+ "image-classification": ["image-classification"],
71
+ "image-segmentation": ["image-segmentation"],
72
+ }
73
+ RUNNER_PAIRS = {
74
+ ("transformers", "text-generation"), ("gguf", "text-generation"),
75
+ ("transformers", "image-text-to-text"), ("gguf", "image-text-to-text"),
76
+ ("transformers", "automatic-speech-recognition"),
77
+ ("transformers", "text-to-speech"),
78
+ ("transformers", "text-summarization"), ("transformers", "text2text-generation"),
79
+ ("transformers", "feature-extraction"), ("transformers", "sentence-similarity"),
80
+ ("transformers", "text-to-image"), ("transformers", "image-to-image"),
81
+ # VIDEO (2026-09-24). Full diffusers video pipelines served by hugpy_video's
82
+ # studio runner registry (RunnerSpec for WAN/LTX/Hunyuan/Cog/Mochi/OpenSora/
83
+ # SkyReels, T2V + I2V + VACE). Declaring the pairs here is what flips a full
84
+ # video pipeline from serveable=False ("adapter / pipeline component") to a
85
+ # normal graded row. Where hugpy-video is not wired on a box,
86
+ # model_resolver._plugin_missing reports it as an uninstalled runner plugin
87
+ # and resolve() refuses at the point of use — it never fails import, and
88
+ # neither task is in TASK_DEFAULTS so no chat/media route can pick a video
89
+ # model.
90
+ ("transformers", "text-to-video"), ("transformers", "image-to-video"),
91
+ # ComfyUI engine rows (slice B) — the checkpoint lives in the WORKER's own
92
+ # ComfyUI install; hugpy holds no files for these.
93
+ ("comfy", "text-to-image"), ("comfy", "image-to-image"),
94
+ ("transformers", "keyword-extraction"),
95
+ ("transformers", "depth-estimation"), ("transformers", "object-detection"),
96
+ ("transformers", "image-classification"), ("transformers", "image-segmentation"),
97
+ }
98
+
99
+ # Frameworks whose ("<framework>","image-to-image") runner+builder pair is wired
100
+ # above. DERIVED from RUNNER_PAIRS so it can't drift (same discipline as
101
+ # frameworks.KNOWN_TASKS_REGISTRY). A generative-image checkpoint on one of these
102
+ # frameworks serves img2img from the SAME weights it serves text-to-image with,
103
+ # so models_config._derive_tasks advertises "image-to-image" for it whenever it
104
+ # advertises "text-to-image". Today this is {"transformers", "comfy"}.
105
+ IMG2IMG_CAPABLE_FRAMEWORKS: frozenset = frozenset(
106
+ framework for (framework, task) in RUNNER_PAIRS if task == "image-to-image"
107
+ )
108
+ MEDIA_DEFAULTS: Dict[str, str] = {
109
+ "document": DEFAULT_CHAT_MODEL,
110
+ "code": DEFAULT_CHAT_MODEL,
111
+ "text": DEFAULT_CHAT_MODEL,
112
+ "image": DEFAULT_VISION_MODEL,
113
+ "audio": DEFAULT_WHISPER_MODEL,
114
+ "video": DEFAULT_WHISPER_MODEL,
115
+ }
116
+
117
+ TASK_DEFAULTS: Dict[str, str] = {
118
+ "text-generation": DEFAULT_CHAT_MODEL,
119
+ "image-text-to-text": DEFAULT_VISION_MODEL,
120
+ "automatic-speech-recognition": DEFAULT_WHISPER_MODEL,
121
+ "text-summarization": DEFAULT_SUMMARIZE_MODEL,
122
+ "text2text-generation": DEFAULT_SUMMARIZE_MODEL,
123
+ "feature-extraction": DEFAULT_EMBED_MODEL,
124
+ "sentence-similarity": DEFAULT_EMBED_MODEL,
125
+ "text-to-image": DEFAULT_IMAGEGEN_MODEL,
126
+ "image-to-image": DEFAULT_IMAGEGEN_MODEL,
127
+ "keyword-extraction": DEFAULT_KEYWORDS_MODEL,
128
+ "depth-estimation": DEFAULT_DEPTH_MODEL,
129
+ "object-detection": DEFAULT_DETECT_MODEL,
130
+ "image-classification": DEFAULT_IMG_CLASSIFY_MODEL,
131
+ "image-segmentation": DEFAULT_SEGMENT_MODEL,
132
+ }
@@ -0,0 +1,84 @@
1
+ """Registry lookups over the live MODEL_REGISTRY (hugpy_next cut 2, lean).
2
+
3
+ The heavy disk/path resolution of the legacy config.main is DROPPED — hugpy
4
+ hosts all weights and the box's fitevict front door owns every load, so the
5
+ engine never resolves a model directory. These helpers answer "what are the
6
+ facts of model X" from the registry (sourced from hugpy_core's catalog).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+ from hugpy_engine.config.models.models_config import (
13
+ MODEL_REGISTRY,
14
+ get_model_registry,
15
+ refresh_registry,
16
+ )
17
+
18
+
19
+ def list_models():
20
+ refresh_registry()
21
+ return list(MODEL_REGISTRY.keys())
22
+
23
+
24
+ def _resolve_model_key(model_key, registry, prefer=None):
25
+ """Map a possibly-bare model_key to a concrete registry key (exact, then a
26
+ unique ``owner~name`` tail, then the ``-GGUF`` format equivalent)."""
27
+ if model_key in registry:
28
+ return model_key
29
+
30
+ def _bare(k):
31
+ return k.split("~", 1)[1] if "~" in k else k
32
+
33
+ candidates = sorted(k for k in registry if _bare(k) == model_key)
34
+ if not candidates:
35
+ try:
36
+ from hugpy_engine.fit.types import key_equivalent
37
+ candidates = sorted(k for k in registry if key_equivalent(_bare(k), model_key))
38
+ except Exception: # noqa: BLE001
39
+ candidates = []
40
+ if not candidates:
41
+ return None
42
+ if len(candidates) == 1:
43
+ return candidates[0]
44
+ for p in (prefer or []):
45
+ if p in candidates:
46
+ return p
47
+ for c in candidates:
48
+ if _bare(c) == _bare(p):
49
+ return c
50
+ return candidates[0]
51
+
52
+
53
+ def get_model_config(model_key: str = None, dict_return=False, return_dict=False,
54
+ key: str = None, prefer=None):
55
+ model_key = model_key or key
56
+ refresh_registry()
57
+ registry = get_model_registry(dict_return=dict_return, return_dict=return_dict)
58
+ resolved = _resolve_model_key(model_key, MODEL_REGISTRY, prefer=prefer)
59
+ if resolved is None:
60
+ raise KeyError(f"Unknown model: {model_key}")
61
+ return registry[resolved]
62
+
63
+
64
+ def get_model_path(key: str) -> Optional[str]:
65
+ """The model's folder as recorded on its registry row. Not load-bearing (the
66
+ front door routes by model_key); best-effort for callers that still ask."""
67
+ cfg = get_model_config(key)
68
+ return getattr(cfg, "folder", None)
69
+
70
+
71
+ def list_model_options():
72
+ refresh_registry()
73
+ return {
74
+ key: {
75
+ "name": cfg.name,
76
+ "hub_id": cfg.hub_id,
77
+ "folder": cfg.folder,
78
+ "tasks": cfg.tasks,
79
+ "framework": cfg.framework,
80
+ "filename": cfg.filename,
81
+ "port": cfg.port,
82
+ }
83
+ for key, cfg in MODEL_REGISTRY.items()
84
+ }
@@ -0,0 +1,190 @@
1
+ """models_config.py — the registry SOURCE (hugpy_next cut 2 seam).
2
+
3
+ Operator ruling "hugpy.json is the model init": per-model facts (framework,
4
+ tasks, primary_task, hub_id, name) are stamped in each model's hugpy.json. The
5
+ worker stamps them, derives the model KEY per box (``owner~repo`` on an owner
6
+ collision, else bare ``repo`` — fitevict/adapters/llama_cpp.discover_models),
7
+ and relays both the key and the parsed hugpy.json to hugpy_core, which exposes
8
+ them at ``GET {core}/models`` (catalog_rows). That endpoint is the ONE place
9
+ that carries BOTH the core id (the id ``/v1/models`` serves and ``pick()``
10
+ routes on) AND each model's hugpy.json facts — the core id is not derivable from
11
+ a single hugpy.json on disk (the collision qualifier is per-box state), so the
12
+ registry is keyed from core, never re-derived from disk.
13
+
14
+ So: MODEL_REGISTRY is built by reading hugpy_core's catalog, keyed by the core
15
+ id, mapping each row's hugpy.json facts through ``derive_model_config_row`` into
16
+ ``ModelConfig``. Read lazily, TTL-cached, fail-open: core unreachable / no
17
+ workers online -> the registry keeps whatever it last had (empty at worst), and
18
+ resolution then refuses cleanly ("no online worker has it" / "no chat default").
19
+
20
+ There is NO curated ``MODELS`` fleet and NO disk discovery/classification here
21
+ (operator: no hardcoded model names; every model already carries its task).
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import logging
27
+ import os
28
+ import threading
29
+ import time
30
+ import urllib.request
31
+ from typing import Dict, Optional, Tuple
32
+
33
+ from hugpy_engine.schemas.model_schemas import ModelConfig
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # No curated staples (operator: no hardcoded model names as defaults). Kept as an
38
+ # empty dict because a few lifted call sites still read ``MODELS`` (e.g.
39
+ # validate_registry's "is this a code-declared staple" check, media defaults).
40
+ MODELS: Dict[str, dict] = {}
41
+
42
+ # The live registry, keyed by the core id. Updated IN PLACE by refresh_registry
43
+ # so modules that imported these names by reference always see the current set.
44
+ MODEL_REGISTRY: Dict[str, ModelConfig] = {}
45
+ MODEL_REGISTRY_DICT: Dict[str, dict] = {}
46
+
47
+ _CORE_URL = (os.environ.get("HUGPY_CORE_URL") or "http://127.0.0.1:7020").rstrip("/")
48
+ _TTL_S = float(os.environ.get("HUGPY_REGISTRY_TTL_S", "30"))
49
+ _TIMEOUT_S = float(os.environ.get("HUGPY_REGISTRY_TIMEOUT_S", "10"))
50
+ _lock = threading.RLock()
51
+ _last_fetch = 0.0
52
+
53
+
54
+ def _dedup(seq) -> list:
55
+ """Order-preserving de-dup, dropping falsy entries."""
56
+ out: list = []
57
+ for x in seq or []:
58
+ if x and x not in out:
59
+ out.append(x)
60
+ return out
61
+
62
+
63
+ def derive_model_config_row(name, row) -> "Tuple[Optional[dict], Optional[str]]":
64
+ """One hugpy_core catalog row -> a ModelConfig-ready dict, or (None, reason).
65
+
66
+ ``name`` is the core id (the registry key). ``row`` is a catalog_rows entry;
67
+ its ``hugpy_json`` sub-dict is authoritative for framework/tasks/
68
+ primary_task/hub_id/name (operator: hugpy.json is the model init), with the
69
+ row's own top-level fields as the fallback the catalog already flattened.
70
+
71
+ framework == the wrapper's engine (gguf, transformers, comfy, ...): one
72
+ concept, kept verbatim. tasks are the plain HF pipeline-tag strings straight
73
+ from hugpy.json, no renaming. A null ``tasks`` (seen on some GGUF markers)
74
+ falls back to ``[primary_task]`` so every model has at least its own task.
75
+ """
76
+ hj = row.get("hugpy_json") if isinstance(row.get("hugpy_json"), dict) else {}
77
+
78
+ framework = hj.get("framework") or row.get("framework")
79
+ if isinstance(framework, (list, tuple)):
80
+ framework = framework[0] if framework else None
81
+ framework = str(framework or "").strip().lower() or None
82
+ if not framework:
83
+ return None, f"{name}: no framework/engine recorded"
84
+
85
+ tasks = hj.get("tasks") if isinstance(hj.get("tasks"), list) else None
86
+ if not tasks:
87
+ tasks = row.get("tasks") if isinstance(row.get("tasks"), list) else None
88
+ primary = hj.get("primary_task") or row.get("primary_task") or row.get("task")
89
+ tasks = _dedup(tasks or ([primary] if primary else []))
90
+ if not tasks:
91
+ return None, f"{name}: no tasks and no primary_task recorded"
92
+ if not primary or primary not in tasks:
93
+ primary = tasks[0]
94
+ else:
95
+ # primary must be present; already is. Keep order with primary first is
96
+ # not required (ModelConfig only checks membership).
97
+ pass
98
+
99
+ hub_id = hj.get("hub_id") or row.get("hub_id") or name
100
+ disp_name = hj.get("name") or row.get("name") or name
101
+ # folder is a required ModelConfig field; it is not load-bearing here (the
102
+ # fitevict front door routes by model_key, not folder), so the hub_id is a
103
+ # fine stable value.
104
+ folder = str(hub_id or name)
105
+
106
+ return {
107
+ "name": disp_name,
108
+ "model_key": name,
109
+ "hub_id": str(hub_id),
110
+ "folder": folder,
111
+ "framework": framework,
112
+ "tasks": tasks,
113
+ "primary_task": primary,
114
+ "filename": hj.get("filename") or row.get("file"),
115
+ "include": hj.get("include"),
116
+ "model_max_length": hj.get("model_max_length") or row.get("model_max_length"),
117
+ }, None
118
+
119
+
120
+ def _fetch_catalog() -> "Optional[list]":
121
+ """GET {core}/models (catalog_rows). None on any failure (fail-open)."""
122
+ url = _CORE_URL + "/models"
123
+ try:
124
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
125
+ with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as r:
126
+ data = json.loads(r.read().decode("utf-8") or "null")
127
+ except Exception as exc: # noqa: BLE001 — the registry read never raises
128
+ logger.warning("registry: could not read %s (%s); keeping the last "
129
+ "known model set (%d models)", url, exc, len(MODEL_REGISTRY))
130
+ return None
131
+ if isinstance(data, dict):
132
+ data = data.get("data") or data.get("models") or []
133
+ return data if isinstance(data, list) else []
134
+
135
+
136
+ def refresh_registry(force: bool = False) -> None:
137
+ """Rebuild MODEL_REGISTRY / MODEL_REGISTRY_DICT from hugpy_core's catalog.
138
+
139
+ TTL-cached (HUGPY_REGISTRY_TTL_S) so a burst of calls hits core once. Updates
140
+ the dicts IN PLACE. Fail-open: a failed/empty fetch leaves the current set
141
+ untouched rather than emptying a working registry."""
142
+ global _last_fetch
143
+ now = time.time()
144
+ with _lock:
145
+ if not force and MODEL_REGISTRY and (now - _last_fetch) < _TTL_S:
146
+ return
147
+ rows = _fetch_catalog()
148
+ _last_fetch = now
149
+ if rows is None:
150
+ return # keep the last known set
151
+ fresh: Dict[str, ModelConfig] = {}
152
+ fresh_dict: Dict[str, dict] = {}
153
+ dropped = []
154
+ for row in rows:
155
+ if not isinstance(row, dict):
156
+ continue
157
+ key = row.get("model_key") or row.get("id") or row.get("name")
158
+ if not key:
159
+ continue
160
+ values, why = derive_model_config_row(key, row)
161
+ if values is None:
162
+ dropped.append((key, why))
163
+ continue
164
+ try:
165
+ cfg = ModelConfig(**values)
166
+ except Exception as exc: # noqa: BLE001 — a bad row never breaks the set
167
+ dropped.append((key, f"ModelConfig rejected: {exc}"))
168
+ continue
169
+ fresh[key] = cfg
170
+ fresh_dict[key] = cfg.to_dict()
171
+ if not fresh and MODEL_REGISTRY:
172
+ # Core answered but yielded nothing usable (all workers offline mid
173
+ # refresh): keep the last good set rather than blanking resolution.
174
+ logger.info("registry: catalog refresh yielded 0 usable rows; "
175
+ "keeping %d previous models", len(MODEL_REGISTRY))
176
+ return
177
+ MODEL_REGISTRY.clear(); MODEL_REGISTRY.update(fresh)
178
+ MODEL_REGISTRY_DICT.clear(); MODEL_REGISTRY_DICT.update(fresh_dict)
179
+ if dropped:
180
+ logger.info("registry: %d/%d catalog rows not servable here: %s",
181
+ len(dropped), len(rows),
182
+ ", ".join(f"{k} ({w})" for k, w in dropped[:6]))
183
+ logger.info("registry: refreshed from %s — %d models",
184
+ _CORE_URL, len(MODEL_REGISTRY))
185
+
186
+
187
+ def get_model_registry(dict_return: bool = False, return_dict: bool = False):
188
+ if dict_return or return_dict:
189
+ return MODEL_REGISTRY_DICT
190
+ return MODEL_REGISTRY
@@ -0,0 +1,150 @@
1
+ """Default-model reconciliation (hugpy_next cut 2, lean).
2
+
3
+ TASK_DEFAULTS / MEDIA_DEFAULTS are seeded in categories.py from the configured
4
+ ``DEFAULT_*`` constants. Those seeds are names a fresh box may not actually
5
+ serve, so they are RECONCILED against the live registry (sourced from
6
+ hugpy_core's catalog, keyed by core id): keep the configured default when the
7
+ registry holds it, else stand in a registry model that serves the task group.
8
+
9
+ Every registry row here is a model a worker is serving RIGHT NOW (it came from
10
+ the live catalog), so "installed" is implicit — there is no disk check and no
11
+ ``route_destination`` import. ``reconcile_defaults()`` and
12
+ ``refresh_task_registries()`` are re-callable so resolve() heals the dicts from
13
+ the current model set on every request.
14
+ """
15
+ from typing import Dict
16
+
17
+ from abstract_essentials import make_list
18
+ from hugpy_engine.categories import MEDIA_DEFAULTS, TASK_DEFAULTS
19
+ from hugpy_engine.config.models.models_config import MODEL_REGISTRY
20
+ from hugpy_engine.schemas.model_schemas import ModelConfig
21
+ from hugpy_platform.constants import (
22
+ DEFAULT_AGENT_BRAIN,
23
+ DEFAULT_CHAT_MODEL,
24
+ DEFAULT_EMBED_MODEL,
25
+ DEFAULT_VISION_MODEL,
26
+ DEFAULT_WHISPER_MODEL,
27
+ )
28
+
29
+
30
+ def get_context_tokens():
31
+ return {k: v.model_max_length for k, v in MODEL_REGISTRY.items()}
32
+
33
+
34
+ def default_context_tokens_for_model(model_key: str) -> int:
35
+ return get_context_tokens().get(model_key, 8192)
36
+
37
+
38
+ def get_models_dict_by_tasks(tasks=None):
39
+ tasks = make_list(tasks or [])
40
+ return {k: v for k, v in MODEL_REGISTRY.items()
41
+ if any(t in tasks for t in v.tasks)}
42
+
43
+
44
+ def get_models_dict_by_names(names=None):
45
+ names = make_list(names or [])
46
+ out = {}
47
+ for k, v in MODEL_REGISTRY.items():
48
+ for name in names:
49
+ if name in v.name:
50
+ out[k] = v
51
+ break
52
+ return out
53
+
54
+
55
+ def _reconcile_default(configured: str, registry: Dict[str, ModelConfig],
56
+ prefer_task: str | None = None) -> str:
57
+ """Keep ``configured`` when the registry holds it; otherwise stand in a
58
+ registry model that serves ``prefer_task`` (then key order for determinism).
59
+ Returns ``configured`` unchanged when the registry is empty, so the honest
60
+ "not in MODEL_REGISTRY" refusal downstream stays accurate."""
61
+ if not registry:
62
+ return configured
63
+ if configured in registry:
64
+ return configured
65
+ pool = registry
66
+ if prefer_task:
67
+ preferred = {k: v for k, v in registry.items() if prefer_task in v.tasks}
68
+ pool = preferred or registry
69
+ if not pool:
70
+ return configured
71
+ return min(pool)
72
+
73
+
74
+ # Seeds from constants.py; healed by reconcile_defaults() against the live set.
75
+ _SEED = {
76
+ "chat": DEFAULT_CHAT_MODEL,
77
+ "vision": DEFAULT_VISION_MODEL,
78
+ "whisper": DEFAULT_WHISPER_MODEL,
79
+ "embed": DEFAULT_EMBED_MODEL,
80
+ "brain": DEFAULT_AGENT_BRAIN,
81
+ }
82
+
83
+ # Task-filtered views (updated IN PLACE — other modules hold them by reference).
84
+ CHAT_MODELS_REGISTRY: Dict[str, ModelConfig] = {}
85
+ VISION_MODELS_REGISTRY: Dict[str, ModelConfig] = {}
86
+ WHISPER_MODELS_REGISTRY: Dict[str, ModelConfig] = {}
87
+ EMBED_MODELS_REGISTRY: Dict[str, ModelConfig] = {}
88
+
89
+ _TASK_REGISTRY_GROUPS = (
90
+ (CHAT_MODELS_REGISTRY, ["text-generation", "text-generation-inference", "text2text-generation"]),
91
+ (VISION_MODELS_REGISTRY, ["image-text-to-text", "text-to-image"]),
92
+ (WHISPER_MODELS_REGISTRY, ["automatic-speech-recognition", "speech-recognition"]),
93
+ (EMBED_MODELS_REGISTRY, ["feature-extraction", "sentence-similarity", "sentence-transformers"]),
94
+ )
95
+
96
+
97
+ def refresh_task_registries() -> None:
98
+ """Rebuild the task-filtered registries in place from the live MODEL_REGISTRY
99
+ (update-then-prune so a concurrent reader never sees an empty dict)."""
100
+ for registry, tasks in _TASK_REGISTRY_GROUPS:
101
+ fresh = get_models_dict_by_tasks(tasks=tasks)
102
+ registry.update(fresh)
103
+ for stale in [k for k in registry if k not in fresh]:
104
+ registry.pop(stale, None)
105
+
106
+
107
+ # The reconciled defaults (module globals, rebound by reconcile_defaults()).
108
+ DEFAULT_CHAT_MODEL = _SEED["chat"]
109
+ DEFAULT_VISION_MODEL = _SEED["vision"]
110
+ DEFAULT_WHISPER_MODEL = _SEED["whisper"]
111
+ DEFAULT_EMBED_MODEL = _SEED["embed"]
112
+ DEFAULT_AGENT_BRAIN = _SEED["brain"]
113
+
114
+
115
+ def reconcile_defaults() -> None:
116
+ """Recompute the DEFAULT_* model keys from the live registry and HEAL
117
+ TASK_DEFAULTS / MEDIA_DEFAULTS in place so a task-only / media-only / bare
118
+ request resolves to a real core id instead of a dangling configured name."""
119
+ global DEFAULT_CHAT_MODEL, DEFAULT_VISION_MODEL, DEFAULT_WHISPER_MODEL
120
+ global DEFAULT_EMBED_MODEL, DEFAULT_AGENT_BRAIN
121
+ refresh_task_registries()
122
+ DEFAULT_CHAT_MODEL = _reconcile_default(_SEED["chat"], CHAT_MODELS_REGISTRY,
123
+ prefer_task="text-generation")
124
+ DEFAULT_AGENT_BRAIN = _reconcile_default(_SEED["brain"], CHAT_MODELS_REGISTRY,
125
+ prefer_task="text-generation")
126
+ DEFAULT_VISION_MODEL = _reconcile_default(_SEED["vision"], VISION_MODELS_REGISTRY,
127
+ prefer_task="image-text-to-text")
128
+ DEFAULT_WHISPER_MODEL = _reconcile_default(_SEED["whisper"], WHISPER_MODELS_REGISTRY,
129
+ prefer_task="automatic-speech-recognition")
130
+ DEFAULT_EMBED_MODEL = _reconcile_default(_SEED["embed"], EMBED_MODELS_REGISTRY,
131
+ prefer_task="feature-extraction")
132
+ reconciled = {
133
+ _SEED["chat"]: DEFAULT_CHAT_MODEL,
134
+ _SEED["vision"]: DEFAULT_VISION_MODEL,
135
+ _SEED["whisper"]: DEFAULT_WHISPER_MODEL,
136
+ _SEED["embed"]: DEFAULT_EMBED_MODEL,
137
+ }
138
+ for defaults, keys_are_tasks in ((TASK_DEFAULTS, True), (MEDIA_DEFAULTS, False)):
139
+ for key, model in list(defaults.items()):
140
+ replacement = reconciled.get(model)
141
+ if not replacement or replacement == model:
142
+ continue
143
+ if keys_are_tasks and replacement in MODEL_REGISTRY \
144
+ and key not in MODEL_REGISTRY[replacement].tasks:
145
+ continue # the stand-in serves the group but not this exact task
146
+ defaults[key] = replacement
147
+
148
+
149
+ # Heal once at import (registry may be empty here -> seeds, re-healed per request).
150
+ reconcile_defaults()
@@ -0,0 +1,36 @@
1
+ """Request dispatch: runner cache, execution, streaming, eviction."""
2
+ from hugpy_engine.dispatch.dispatch import (
3
+ LoadRefusal,
4
+ clear,
5
+ ensure_headroom_for_load,
6
+ evict,
7
+ execute_chat_stream,
8
+ execute_prompt,
9
+ execute_prompt_stream,
10
+ infer_arg_name,
11
+ last_used_snapshot,
12
+ loaded_disk_detail,
13
+ loaded_model_keys,
14
+ loading_model_keys,
15
+ no_makeroom_active,
16
+ normalize_prompt_kwargs,
17
+ runner_for,
18
+ set_evict_reason,
19
+ set_evictable,
20
+ set_fit_check,
21
+ set_make_room,
22
+ set_post_evict_hook,
23
+ stream_runner,
24
+ supported_task_keys,
25
+ touch_model,
26
+ )
27
+
28
+ __all__ = [
29
+ "LoadRefusal", "clear", "ensure_headroom_for_load", "evict",
30
+ "execute_chat_stream", "execute_prompt", "execute_prompt_stream",
31
+ "infer_arg_name", "last_used_snapshot", "loaded_disk_detail",
32
+ "loaded_model_keys", "loading_model_keys", "no_makeroom_active",
33
+ "normalize_prompt_kwargs", "runner_for", "set_evict_reason", "set_evictable",
34
+ "set_fit_check", "set_make_room", "set_post_evict_hook", "stream_runner",
35
+ "supported_task_keys", "touch_model",
36
+ ]