hugpy-engine 0.3.2__tar.gz → 0.3.4__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.2 → hugpy_engine-0.3.4}/PKG-INFO +1 -1
  2. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/pyproject.toml +1 -1
  3. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/categories.py +7 -16
  4. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/config/models/models_config.py +43 -0
  5. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/resolvers/model_resolver.py +50 -22
  6. hugpy_engine-0.3.4/src/hugpy_engine/task_endpoints.py +239 -0
  7. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine.egg-info/PKG-INFO +1 -1
  8. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine.egg-info/SOURCES.txt +1 -0
  9. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/README.md +0 -0
  10. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/setup.cfg +0 -0
  11. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/__init__.py +0 -0
  12. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/config/__init__.py +0 -0
  13. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/config/main.py +0 -0
  14. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/config/models/__init__.py +0 -0
  15. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/config/models/models_default.py +0 -0
  16. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/dispatch/__init__.py +0 -0
  17. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/dispatch/dispatch.py +0 -0
  18. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/fit/__init__.py +0 -0
  19. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/fit/types.py +0 -0
  20. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/llama/__init__.py +0 -0
  21. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/llama/runners/__init__.py +0 -0
  22. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/llama/runners/fitevict_runner.py +0 -0
  23. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/name_match.py +0 -0
  24. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/placement.py +0 -0
  25. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/py.typed +0 -0
  26. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/resolvers/__init__.py +0 -0
  27. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/resolvers/assure_model_key.py +0 -0
  28. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/resolvers/categories/__init__.py +0 -0
  29. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/resolvers/categories/builders.py +0 -0
  30. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/resolvers/categories/frameworks.py +0 -0
  31. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/schemas/__init__.py +0 -0
  32. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/schemas/chat_schemas.py +0 -0
  33. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/schemas/event_schemas.py +0 -0
  34. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/schemas/metadata_schemas.py +0 -0
  35. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/schemas/model_schemas.py +0 -0
  36. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/schemas/runner_schemas.py +0 -0
  37. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/schemas/task_schemas.py +0 -0
  38. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/task_deps.py +0 -0
  39. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine/tasks.py +0 -0
  40. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine.egg-info/dependency_links.txt +0 -0
  41. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/src/hugpy_engine.egg-info/requires.txt +0 -0
  42. {hugpy_engine-0.3.2 → hugpy_engine-0.3.4}/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.2
3
+ Version: 0.3.4
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.2"
7
+ version = "0.3.4"
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"
@@ -114,19 +114,10 @@ MEDIA_DEFAULTS: Dict[str, str] = {
114
114
  "video": DEFAULT_WHISPER_MODEL,
115
115
  }
116
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
- }
117
+ # TASK DEFAULTS ARE NOT CONSTANTS ANY MORE (operator ruling 2026-10-04: "each
118
+ # task is BOUND to a designated model in the DB. No guessing, no constants").
119
+ # The task -> model_key map now lives in the DB (settings_policies
120
+ # "task_bindings") and is read live from hugpy_core's GET /prompt/tasks
121
+ # ``defaults`` via config.models.models_config.task_bindings(). This name is
122
+ # kept EMPTY only so a mid-rollout importer does not break; nothing populates it.
123
+ TASK_DEFAULTS: Dict[str, str] = {}
@@ -188,3 +188,46 @@ def get_model_registry(dict_return: bool = False, return_dict: bool = False):
188
188
  if dict_return or return_dict:
189
189
  return MODEL_REGISTRY_DICT
190
190
  return MODEL_REGISTRY
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Task bindings — the task -> model_key map, from the DB via hugpy_core.
195
+ #
196
+ # Operator ruling 2026-10-04: "each task is BOUND to a designated model in the
197
+ # DB. No guessing, no constants." The bindings live in settings_policies
198
+ # ("task_bindings") and hugpy_core exposes them as the ``defaults`` of
199
+ # ``GET {core}/prompt/tasks``. The engine reads THAT (never a hardcoded
200
+ # categories table): TTL-cached, fail-open to the last-known map (empty at
201
+ # worst, which yields the honest "no model bound to task X" refusal).
202
+ # ---------------------------------------------------------------------------
203
+ _bindings: dict[str, str] = {}
204
+ _bindings_at = 0.0
205
+
206
+
207
+ def task_bindings(force: bool = False) -> dict:
208
+ """The live {task: model_key} bindings from hugpy_core's /prompt/tasks
209
+ ``defaults``. TTL-cached; a failed/empty read keeps the last-known map."""
210
+ global _bindings, _bindings_at
211
+ now = time.time()
212
+ if not force and _bindings and (now - _bindings_at) < _TTL_S:
213
+ return dict(_bindings)
214
+ url = _CORE_URL + "/prompt/tasks"
215
+ try:
216
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
217
+ with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as r:
218
+ data = json.loads(r.read().decode("utf-8") or "null")
219
+ except Exception as exc: # noqa: BLE001 — never raise on the resolve path
220
+ logger.warning("registry: could not read task bindings from %s (%s); "
221
+ "keeping the last-known map (%d)", url, exc, len(_bindings))
222
+ _bindings_at = now
223
+ return dict(_bindings)
224
+ defaults = (data or {}).get("defaults") if isinstance(data, dict) else None
225
+ if isinstance(defaults, dict):
226
+ _bindings = {str(k): str(v) for k, v in defaults.items() if v}
227
+ _bindings_at = now
228
+ return dict(_bindings)
229
+
230
+
231
+ def task_binding(task: str) -> "Optional[str]":
232
+ """The model_key bound to ``task``, or None (no binding)."""
233
+ return task_bindings().get(task)
@@ -26,13 +26,16 @@ from abstract_essentials import derive_media_type, safe_load_from_json
26
26
  from pydantic import BaseModel
27
27
  import logging
28
28
  logger = logging.getLogger(__name__)
29
- from hugpy_engine.categories import MEDIA_DEFAULTS, TASK_DEFAULTS
30
- from hugpy_engine.config.models.models_config import MODELS, MODEL_REGISTRY, refresh_registry
29
+ from hugpy_engine.categories import MEDIA_DEFAULTS
30
+ from hugpy_engine.config.models.models_config import (
31
+ MODELS, MODEL_REGISTRY, refresh_registry, task_bindings)
31
32
  from hugpy_engine.config.models.models_default import DEFAULT_CHAT_MODEL
32
33
  from hugpy_engine.schemas.task_schemas import Resolution
33
34
  from hugpy_engine.resolvers.categories.builders import MODEL_REQUEST_BUILDERS
34
35
  from hugpy_engine.resolvers.categories.frameworks import FRAMEWORK_RUNNERS, KNOWN_TASKS_REGISTRY
35
36
  from hugpy_engine.resolvers.assure_model_key import assure_model_key
37
+ from hugpy_engine.task_endpoints import (
38
+ TASK_ROUTES, TaskEndpointRunner, build_endpoint_request)
36
39
 
37
40
  # ---------------------------------------------------------------------------
38
41
  # Unclassified / adapter rows — refuse by NAME, never by assumption (k61).
@@ -137,9 +140,9 @@ def resolve_model_key(
137
140
  explicit representation override — authoritative when choosing between the
138
141
  GGUF and Transformers rows of the same model (operator 2026-09-29).
139
142
  """
140
- if task is not None and task not in KNOWN_TASKS_REGISTRY:
143
+ if task is not None and task not in KNOWN_TASKS_REGISTRY and task not in TASK_ROUTES:
141
144
  raise KeyError(
142
- f"Unknown task={task!r}; known: {sorted(KNOWN_TASKS_REGISTRY)}"
145
+ f"Unknown task={task!r}; known: {sorted(set(KNOWN_TASKS_REGISTRY) | set(TASK_ROUTES))}"
143
146
  )
144
147
  fmt = (model_format or "auto").strip().lower() if model_format else "auto"
145
148
  if fmt not in _MODEL_FORMATS:
@@ -177,22 +180,25 @@ def resolve_model_key(
177
180
  return model_key
178
181
 
179
182
  if task is not None:
180
- inferred = TASK_DEFAULTS.get(task)
183
+ # Task -> model is a DB BINDING (settings_policies "task_bindings"), read
184
+ # live from hugpy_core's /prompt/tasks defaults. No guessing, no constants.
185
+ inferred = task_bindings().get(task)
181
186
  if inferred is None:
182
187
  raise KeyError(
183
- f"No default model for task={task!r}; "
184
- f"tasks with defaults: {sorted(TASK_DEFAULTS)}"
188
+ f"no model bound to task {task!r}; bind one in Settings "
189
+ f"(task_bindings)"
185
190
  )
186
191
  if inferred not in MODEL_REGISTRY:
187
192
  raise KeyError(
188
- f"Task default {inferred!r} for {task!r} not in MODEL_REGISTRY:{MODEL_REGISTRY}"
193
+ f"model {inferred!r} bound to task {task!r} is not in the registry "
194
+ f"— no online worker has it (see /models)"
189
195
  )
190
196
  if task not in MODEL_REGISTRY[inferred].tasks:
191
197
  raise ValueError(
192
- f"Task default {inferred!r} for {task!r} does not list "
193
- f"{task!r} in cfg.tasks={sorted(MODEL_REGISTRY[inferred].tasks)!r}"
198
+ f"model {inferred!r} bound to task {task!r} does not list it in its "
199
+ f"hugpy.json tasks={sorted(MODEL_REGISTRY[inferred].tasks)!r}"
194
200
  )
195
- logger.debug("resolve_model_key: task=%s -> key=%s", task, inferred)
201
+ logger.debug("resolve_model_key: task=%s -> bound key=%s", task, inferred)
196
202
  return inferred
197
203
 
198
204
  if media_type is None and file is not None:
@@ -218,20 +224,21 @@ def resolve_model_key(
218
224
  logger.debug("resolve_model_key: media=%s -> key=%s", media_type, inferred)
219
225
  return inferred
220
226
 
221
- # Bare chat default — no model_key, task, media or file given. TASK_DEFAULTS
222
- # is healed IN PLACE from the live registry (reconcile_defaults), so it holds
223
- # a real core id for "text-generation"; the module-level DEFAULT_CHAT_MODEL
224
- # is only the import-time seed. No data at all -> a clean, honest refusal
225
- # (fail-as-data), never a surprise.
226
- chat_default = TASK_DEFAULTS.get("text-generation") or DEFAULT_CHAT_MODEL
227
+ # Bare chat default — no model_key, task, media or file given. The default
228
+ # is the DB BINDING for text-generation (hugpy_core /prompt/tasks), never a
229
+ # constant. No binding -> an honest refusal (fail-as-data), never a surprise.
230
+ chat_default = task_bindings().get("text-generation")
231
+ if not chat_default:
232
+ raise KeyError(
233
+ "no model bound to task 'text-generation'; bind one in Settings "
234
+ "(task_bindings), or pass an explicit model_key"
235
+ )
227
236
  if chat_default not in MODEL_REGISTRY:
228
237
  raise KeyError(
229
- f"no chat default available: the registry has no text-generation "
230
- f"model (configured seed {DEFAULT_CHAT_MODEL!r}, healed default "
231
- f"{chat_default!r}). Pass an explicit model_key, or make sure a "
232
- f"chat-capable model is online on a worker."
238
+ f"model {chat_default!r} bound to task 'text-generation' is not in the "
239
+ f"registry — no online worker has it (see /models)"
233
240
  )
234
- logger.debug("resolve_model_key: fallback to chat default=%s", chat_default)
241
+ logger.debug("resolve_model_key: fallback to bound chat default=%s", chat_default)
235
242
  return chat_default
236
243
 
237
244
 
@@ -334,6 +341,27 @@ def resolve(prompt_kwargs: Dict[str, Any]) -> Resolution:
334
341
 
335
342
  key = (framework, task)
336
343
 
344
+ # NON-CHAT TASKS -> the dedicated front-door route (hugpy_next cut 3). These
345
+ # bypass the plugin runner table ON PURPOSE: the media/video engine-plugins
346
+ # register IN-PROCESS runners for these tasks, and nothing may load a model
347
+ # in core (one GPU owner per box = the wrapper). resolve() routes them
348
+ # straight to TaskEndpointRunner, which relays to hugpy_core's /v1/<route>;
349
+ # core picks the worker holding this model (the same pick() as chat).
350
+ _endpoint = TASK_ROUTES.get(task)
351
+ if _endpoint is not None:
352
+ _route, _output = _endpoint
353
+
354
+ def _builder(_kw, _mk, *, _t=task, _r=_route, _o=_output):
355
+ return build_endpoint_request(_kw, _mk, task=_t, route=_r, output=_o)
356
+
357
+ logger.debug("resolve: model=%s task=%s -> front-door %s",
358
+ model_key, task, _route)
359
+ return Resolution(
360
+ model_key=model_key, framework=framework, task=task, cfg=cfg,
361
+ builder=_builder, runner_cls=TaskEndpointRunner,
362
+ cache_key=(model_key, task),
363
+ )
364
+
337
365
  builder = MODEL_REQUEST_BUILDERS.get(key)
338
366
  if builder is None:
339
367
  raise KeyError(
@@ -0,0 +1,239 @@
1
+ """Non-chat task dispatch — one HTTP relay per task family through the front door.
2
+
3
+ Chat (text-generation / image-text-to-text) rides FitevictChatRunner ->
4
+ hugpy_core ``/v1/chat/completions``. Every OTHER served task (embeddings,
5
+ summarize, keywords, ASR, TTS, image/video generation) has a dedicated
6
+ front-door route; this module is the engine side of that:
7
+
8
+ * :data:`TASK_ROUTES` — the task -> (route, output) PROTOCOL table. It MIRRORS
9
+ the wrapper's RunnerSpec ``tasks``/``endpoint`` (fitevict/lifted/runners).
10
+ Routes are protocol, so they live in code; MODEL NAMES never do (bindings
11
+ come from the DB via hugpy_core, see config.models.models_config.task_bindings).
12
+ * :func:`build_endpoint_request` — maps execute_prompt kwargs onto each route's
13
+ JSON body using the hugpy-media / abstract-hugpy field names (text/texts/
14
+ prompt -> input, file -> file_path, summary_mode/top_n/... pass through).
15
+ * :class:`TaskEndpointRunner` — POSTs ``{HUGPY_CORE_URL}/v1/<route>`` with that
16
+ body (+ ``model``), and returns the response as a TaskResult. hugpy_core
17
+ picks the worker holding ``body.model`` (same pick() as chat); the box's
18
+ wrapper owns the load, serving and call_log. Nothing loads a model in core.
19
+
20
+ ``resolve()`` selects this runner for any task in :data:`TASK_ROUTES`, ahead of
21
+ the plugin runner table — the media/video engine-plugins register in-process
22
+ runners that must NOT run in core (one GPU owner per box = the wrapper).
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+
28
+ import asyncio
29
+ import json
30
+ import logging
31
+ import os
32
+ import urllib.error
33
+ import urllib.request
34
+ from typing import Optional
35
+
36
+ from pydantic import ConfigDict
37
+
38
+ from hugpy_engine.schemas.event_schemas import DoneEvent, ErrorEvent, TokenEvent
39
+ from hugpy_engine.schemas.task_schemas import TaskRequest, TaskResult
40
+ from hugpy_platform.utils import make_request_id
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ CORE_URL = (os.environ.get("HUGPY_CORE_URL")
45
+ or os.environ.get("HUGPY_FITEVICT_FRONT_DOOR")
46
+ or "http://127.0.0.1:7020").rstrip("/")
47
+ _TIMEOUT_S = float(os.environ.get("HUGPY_TASK_ENDPOINT_TIMEOUT_S", "900"))
48
+
49
+
50
+ # task -> (front-door route, output kind). Mirrors the wrapper RunnerSpecs:
51
+ # embed /v1/embeddings | summarize /v1/summarize | keywords /v1/keywords |
52
+ # asr /v1/audio/transcriptions | tts /v1/audio/speech |
53
+ # imagegen /v1/images/generations | videogen /v1/videos/generations
54
+ # The HF pipeline-tag spellings a hugpy.json may carry are all mapped (the
55
+ # wrapper's spec.tasks list the same aliases).
56
+ TASK_ROUTES: dict[str, tuple[str, str]] = {
57
+ "feature-extraction": ("/v1/embeddings", "json"),
58
+ "sentence-similarity": ("/v1/embeddings", "json"),
59
+ "text-summarization": ("/v1/summarize", "json"),
60
+ "summarization": ("/v1/summarize", "json"),
61
+ "text2text-generation": ("/v1/summarize", "json"),
62
+ "keyword-extraction": ("/v1/keywords", "json"),
63
+ "automatic-speech-recognition": ("/v1/audio/transcriptions", "json"),
64
+ "text-to-speech": ("/v1/audio/speech", "audio"),
65
+ "text-to-image": ("/v1/images/generations", "image"),
66
+ "image-to-image": ("/v1/images/generations", "image"),
67
+ "inpainting": ("/v1/images/generations", "image"),
68
+ "text-to-video": ("/v1/videos/generations", "video"),
69
+ "image-to-video": ("/v1/videos/generations", "video"),
70
+ }
71
+
72
+
73
+ def route_for_task(task: str) -> "Optional[tuple[str, str]]":
74
+ return TASK_ROUTES.get(task)
75
+
76
+
77
+ # ── request-body mapping, per ROUTE (shared across the tasks on that route) ──
78
+ # ``input_key``: where the primary payload lands; ``sources``: kwargs checked in
79
+ # order for it; ``file_sources``: folded to ``file_path``; ``passthrough``:
80
+ # body params copied verbatim when present. The hugpy-media / abstract-hugpy
81
+ # field names, so the wrapper runners (copied from hugpy_media) read them 1:1.
82
+ _BODY_SPECS: dict[str, dict] = {
83
+ "/v1/embeddings": {
84
+ "input_key": "input", "sources": ("input", "texts", "text", "prompt"),
85
+ "passthrough": ("other_texts", "normalize"),
86
+ },
87
+ "/v1/summarize": {
88
+ "input_key": "input", "sources": ("input", "text", "prompt"),
89
+ "passthrough": ("max_length", "min_length", "max_chunk_tokens",
90
+ "summary_mode"),
91
+ },
92
+ "/v1/keywords": {
93
+ "input_key": "input", "sources": ("input", "text", "prompt"),
94
+ "passthrough": ("top_n", "diversity", "use_mmr", "stop_words",
95
+ "keyphrase_ngram_range"),
96
+ },
97
+ "/v1/audio/transcriptions": {
98
+ "input_key": None,
99
+ "file_sources": ("file_path", "file", "path", "image_path"),
100
+ "passthrough": ("file_b64", "language", "translate", "prompt",
101
+ "temperature", "response_format"),
102
+ },
103
+ "/v1/audio/speech": {
104
+ "input_key": "input", "sources": ("input", "text", "prompt"),
105
+ "passthrough": ("voice", "response_format", "format", "speed"),
106
+ },
107
+ "/v1/images/generations": {
108
+ "input_key": "prompt", "sources": ("prompt", "input", "text"),
109
+ "passthrough": ("n", "size", "steps", "num_inference_steps",
110
+ "guidance_scale", "negative_prompt", "seed", "width",
111
+ "height", "image", "mask", "strength", "_route"),
112
+ },
113
+ "/v1/videos/generations": {
114
+ "input_key": "prompt", "sources": ("prompt", "input", "text"),
115
+ "passthrough": ("num_frames", "fps", "seed", "width", "height", "steps",
116
+ "num_inference_steps", "image", "negative_prompt"),
117
+ },
118
+ }
119
+
120
+
121
+ class EndpointRequest(TaskRequest):
122
+ """A built non-chat request: the resolved front-door ``route`` + JSON ``body``
123
+ (model-less; the runner adds ``model``). ``output`` is the artifact kind."""
124
+ model_config = ConfigDict(extra="forbid")
125
+ route: str
126
+ body: dict = {}
127
+ output: str = "json"
128
+ timeout_s: float = _TIMEOUT_S
129
+
130
+
131
+ def build_endpoint_request(kwargs: dict, model_key: str, *, task: str,
132
+ route: str, output: str = "json") -> EndpointRequest:
133
+ """execute_prompt kwargs -> the front-door JSON body for ``route``."""
134
+ spec = _BODY_SPECS.get(route, {"input_key": "input",
135
+ "sources": ("input", "text", "prompt"),
136
+ "passthrough": ()})
137
+ body: dict = {}
138
+ file_sources = spec.get("file_sources") or ()
139
+ for k in file_sources:
140
+ v = kwargs.get(k)
141
+ if v:
142
+ # The serving box is whichever core picks — it does not share this
143
+ # host's disk. A file readable HERE travels as bytes (file_b64, which
144
+ # every runner's input_file() accepts); anything else (a path only the
145
+ # box knows) is passed as-is.
146
+ if isinstance(v, str) and os.path.isfile(v):
147
+ with open(v, "rb") as fh:
148
+ body["file_b64"] = base64.b64encode(fh.read()).decode("ascii")
149
+ body["filename"] = os.path.basename(v)
150
+ else:
151
+ body["file_path"] = v
152
+ break
153
+ input_key = spec.get("input_key")
154
+ if input_key:
155
+ for k in spec.get("sources", ()):
156
+ if kwargs.get(k) is not None:
157
+ body[input_key] = kwargs[k]
158
+ break
159
+ for k in spec.get("passthrough", ()):
160
+ if kwargs.get(k) is not None:
161
+ body[k] = kwargs[k]
162
+ rid = kwargs.get("request_id") or make_request_id()
163
+ return EndpointRequest(request_id=rid, model_key=model_key, route=route,
164
+ body=body, output=output,
165
+ pool=kwargs.get("pool"))
166
+
167
+
168
+ class TaskEndpointRunner:
169
+ """Relays one non-chat request to hugpy_core's front-door route and returns
170
+ its response as a TaskResult. One class for every route — the route rides the
171
+ request (EndpointRequest.route), so dispatch's ``runner_cls(cfg)`` build is
172
+ uniform and the per-(model_key, task) instance cache still applies."""
173
+
174
+ request_type = EndpointRequest
175
+ result_type = TaskResult
176
+
177
+ def __init__(self, cfg, **runtime_kwargs):
178
+ self.cfg = cfg
179
+ self.model_key = getattr(cfg, "model_key", None)
180
+
181
+ def _post(self, url: str, body: dict, timeout: float) -> "tuple[int, dict]":
182
+ req = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"),
183
+ headers={"Content-Type": "application/json"},
184
+ method="POST")
185
+ try:
186
+ with urllib.request.urlopen(req, timeout=timeout) as r:
187
+ return r.status, json.loads(r.read().decode("utf-8") or "{}")
188
+ except urllib.error.HTTPError as exc:
189
+ try:
190
+ return exc.code, json.loads(exc.read().decode("utf-8") or "{}")
191
+ except Exception: # noqa: BLE001
192
+ return exc.code, {"error": {"message": str(exc)}}
193
+
194
+ @staticmethod
195
+ def _error(status: int, payload) -> str:
196
+ err = payload.get("error") if isinstance(payload, dict) else None
197
+ msg = err.get("message") if isinstance(err, dict) else err
198
+ return f"front door {status}: {msg or payload}"
199
+
200
+ def _result(self, req: EndpointRequest, status: int, payload) -> TaskResult:
201
+ ok = status == 200
202
+ fields = dict(payload) if isinstance(payload, dict) else {"data": payload}
203
+ for k in ("request_id", "model_key", "ok", "error"):
204
+ fields.pop(k, None)
205
+ return TaskResult(request_id=req.request_id, model_key=req.model_key,
206
+ ok=ok, error=(None if ok else self._error(status, payload)),
207
+ **fields)
208
+
209
+ async def run(self, req) -> TaskResult:
210
+ if not isinstance(req, EndpointRequest):
211
+ raise TypeError("TaskEndpointRunner requires an EndpointRequest")
212
+ body = {**req.body, "model": req.model_key}
213
+ status, payload = await asyncio.to_thread(
214
+ self._post, CORE_URL + req.route, body, req.timeout_s)
215
+ return self._result(req, status, payload)
216
+
217
+ async def stream(self, req: EndpointRequest, cancel_event=None):
218
+ if cancel_event is not None and cancel_event.is_set():
219
+ yield DoneEvent(request_id=req.request_id, input_tokens=0,
220
+ output_chunks=0, finish_reason="cancelled")
221
+ return
222
+ try:
223
+ res = await self.run(req)
224
+ except Exception as exc: # noqa: BLE001 — a transport fault is an event
225
+ yield ErrorEvent(request_id=req.request_id,
226
+ message=f"front door unreachable: {exc}")
227
+ return
228
+ if not res.ok:
229
+ yield ErrorEvent(request_id=req.request_id, message=res.error or "run failed")
230
+ return
231
+ text = getattr(res, "text", None)
232
+ if text:
233
+ yield TokenEvent(request_id=req.request_id, text=str(text))
234
+ yield DoneEvent(request_id=req.request_id, input_tokens=0, output_chunks=1,
235
+ finish_reason="stop")
236
+
237
+
238
+ __all__ = ["TASK_ROUTES", "route_for_task", "build_endpoint_request",
239
+ "EndpointRequest", "TaskEndpointRunner", "CORE_URL"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hugpy-engine
3
- Version: 0.3.2
3
+ Version: 0.3.4
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
@@ -6,6 +6,7 @@ src/hugpy_engine/name_match.py
6
6
  src/hugpy_engine/placement.py
7
7
  src/hugpy_engine/py.typed
8
8
  src/hugpy_engine/task_deps.py
9
+ src/hugpy_engine/task_endpoints.py
9
10
  src/hugpy_engine/tasks.py
10
11
  src/hugpy_engine.egg-info/PKG-INFO
11
12
  src/hugpy_engine.egg-info/SOURCES.txt
File without changes
File without changes