Проверено исполнением на сервере владельца, а не по отчёту.
Работает. Сжатие вызывается в настоящем пути запроса (local_adapter.py:168) —
разрыв, ради которого писалось задание, закрыт. Замер на живом компрессоре:
5667 токенов на входе, 624 на выходе, 0,11x, экономия 5043 токена за 127,6 с.
Исправлено четыре дефекта.
1. /props и /tokenize запрашивались по адресу с суффиксом /v1. У llama.cpp они
живут в корне: измерено, /props → 200, /v1/props → 404, то же с /tokenize.
Адаптер передаёт супервизору именно адрес с /v1, поэтому счёт токенов молча
падал на посимвольную оценку, и порог сжатия считался от выдуманного числа.
Адрес нормализуется.
2. Заявленные «сто процентов сохранения фактов» модель не даёт. Замер: 37 из 38,
97,4%, потерян 001cd1f. Сто процентов получались дописыванием недостающих
фактов списком — механизм верный, но измеренное число подменялось
исправленным, а «(100%)» было вписано в сообщение текстом. Теперь полнота
самой модели сохраняется отдельно и показывается владельцу: иначе ухудшение
модели осталось бы незамеченным.
3. Итог сжатия считался посимвольно (длина / 3.5) и подавался рядом с настоящим
числом токенов на входе. Теперь пересчитывается токенизатором сервера, а
недоступность токенизатора помечается признаком оценки.
4. Порт 8082 был зашит запасным адресом в двух местах вопреки прямому запрету в
задании. Профиль без адреса теперь даёт состояние «не настроен» с причиной,
а не молчаливый стук в 8082.
Не выполнено исполнителем: проверка на живом сервере (P0-6.1). Тест на неё
пропускается как негерметичный, замеры выше сделал ревьюер.
649 passed, 2 skipped; ruff чисто; релизный гейт пройден.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
514 lines
23 KiB
Python
514 lines
23 KiB
Python
"""Hermes Hub Local Model Supervisor (Надзиратель локальных моделей).
|
||
|
||
Implements P0-4, P0-5, P0-6, P0-7, P0-8 for Task A52:
|
||
- P0-4: Automated supervisor role for local providers (local, llama.cpp, ollama, vllm)
|
||
- P0-5: Measured context limits via /props and exact token counting via /tokenize
|
||
- P0-6: Semantic task splitting across file/class/function boundaries with sequential delivery
|
||
- P0-7: Execution monitoring, distinguishing SUCCESS, TIMEOUT, ERROR, and REASONING_EXHAUSTED (A39)
|
||
- P0-8: Shared memory tracking in AI-Memory by GGUF build metadata
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
from dataclasses import asdict, dataclass, field
|
||
from datetime import datetime, timezone
|
||
from enum import Enum
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from .context_compressor import (
|
||
COMPRESSION_MEMORY_FILE,
|
||
CompressionOutcome,
|
||
ContextCompressor,
|
||
FactualEntities,
|
||
extract_factual_entities,
|
||
verify_facts_retention,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
SHARED_MEMORY_VAULT = Path("/srv/projects/AI-Memory")
|
||
LOCAL_MEMORY_FILE = SHARED_MEMORY_VAULT / "01_PROJECTS" / "hermes-hub" / "local_models_memory.json"
|
||
|
||
|
||
class SupervisorOutcome(str, Enum):
|
||
SUCCESS = "SUCCESS"
|
||
TIMEOUT = "TIMEOUT"
|
||
ERROR = "ERROR"
|
||
REASONING_EXHAUSTED = "REASONING_EXHAUSTED"
|
||
|
||
|
||
class IndivisibleTaskError(Exception):
|
||
"""Raised when a single code block cannot be semantically split and exceeds model context."""
|
||
pass
|
||
|
||
|
||
class ContextExhaustedError(Exception):
|
||
"""Raised when maximum split retries are exhausted without successful generation."""
|
||
pass
|
||
|
||
|
||
@dataclass
|
||
class TokenCountResult:
|
||
tokens_count: int
|
||
is_estimated: bool
|
||
method: str # "tokenize_api" or "char_heuristic"
|
||
|
||
|
||
@dataclass
|
||
class ServerPropsResult:
|
||
n_ctx: int
|
||
total_slots: int
|
||
model_name: str
|
||
model_path: str
|
||
is_measured: bool
|
||
|
||
|
||
@dataclass
|
||
class ModelMemoryRecord:
|
||
gguf_name: str
|
||
safe_chunk_tokens: int
|
||
max_tested_tokens: int
|
||
successful_dispatches: int
|
||
failed_dispatches: int
|
||
last_working_context: int
|
||
avg_generation_tps: float
|
||
last_updated: str
|
||
history: List[Dict[str, Any]] = field(default_factory=list)
|
||
|
||
|
||
# Разделитель между сообщениями при подсчёте объёма контекста.
|
||
MESSAGE_SEPARATOR = "\n\n"
|
||
|
||
|
||
class LocalSupervisor:
|
||
"""Oversees and regulates work dispatch to local models."""
|
||
|
||
DEFAULT_SAFETY_MARGIN_TOKENS: int = 1024
|
||
DEFAULT_RESPONSE_MARGIN_TOKENS: int = 4096
|
||
MAX_SPLIT_ATTEMPTS: int = 3
|
||
|
||
def __init__(
|
||
self,
|
||
base_url: str = "http://127.0.0.1:8081",
|
||
memory_path: Optional[Path] = None,
|
||
compressor: Optional[ContextCompressor] = None,
|
||
):
|
||
self.base_url = base_url.rstrip("/")
|
||
# /props и /tokenize у llama.cpp живут в КОРНЕ, а не под /v1. Проверено
|
||
# на сервере владельца: /props → 200, /v1/props → 404; то же с
|
||
# /tokenize. Адаптер передаёт сюда адрес вида .../v1, поэтому без
|
||
# нормализации оба запроса получали 404, счёт токенов молча падал на
|
||
# посимвольную оценку, и порог сжатия считался от выдуманного числа.
|
||
self.root_url = self.base_url[:-3].rstrip("/") if self.base_url.endswith("/v1") else self.base_url
|
||
self.memory_path = memory_path or LOCAL_MEMORY_FILE
|
||
self.compressor = compressor or ContextCompressor()
|
||
|
||
# -------------------------------------------------------------
|
||
# P0-5: Measured limits via /props and /tokenize
|
||
# -------------------------------------------------------------
|
||
def query_server_props(self, timeout_sec: float = 3.0) -> ServerPropsResult:
|
||
"""Query real model properties and context limits from live server."""
|
||
props_url = f"{self.root_url}/props"
|
||
try:
|
||
req = urllib.request.Request(props_url, headers={"User-Agent": "Hermes-LocalSupervisor/1.0"})
|
||
with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
|
||
data = json.loads(resp.read().decode("utf-8"))
|
||
gen_settings = data.get("default_generation_settings", {})
|
||
n_ctx = int(gen_settings.get("n_ctx") or data.get("n_ctx") or 65536)
|
||
total_slots = int(data.get("total_slots", 1))
|
||
model_path = str(data.get("model_path") or data.get("model_alias") or "")
|
||
|
||
# Extract clean GGUF model name
|
||
model_name = Path(model_path).stem if model_path else "local-model"
|
||
return ServerPropsResult(
|
||
n_ctx=n_ctx,
|
||
total_slots=total_slots,
|
||
model_name=model_name,
|
||
model_path=model_path,
|
||
is_measured=True,
|
||
)
|
||
except Exception as err:
|
||
logger.warning("Failed to query /props from %s: %s (using unverified fallback)", props_url, err)
|
||
return ServerPropsResult(
|
||
n_ctx=65536,
|
||
total_slots=1,
|
||
model_name="local-model-unverified",
|
||
model_path="",
|
||
is_measured=False,
|
||
)
|
||
|
||
def count_tokens(self, text: str, timeout_sec: float = 3.0) -> TokenCountResult:
|
||
"""Count tokens accurately via /tokenize endpoint with fallback character heuristic."""
|
||
if not text:
|
||
return TokenCountResult(tokens_count=0, is_estimated=False, method="exact_empty")
|
||
|
||
tok_url = f"{self.root_url}/tokenize"
|
||
try:
|
||
payload = json.dumps({"content": text}).encode("utf-8")
|
||
req = urllib.request.Request(
|
||
tok_url,
|
||
data=payload,
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
|
||
data = json.loads(resp.read().decode("utf-8"))
|
||
tokens = data.get("tokens", [])
|
||
return TokenCountResult(
|
||
tokens_count=len(tokens),
|
||
is_estimated=False,
|
||
method="tokenize_api",
|
||
)
|
||
except Exception as err:
|
||
logger.warning("Failed to /tokenize with %s: %s. Using heuristic estimate.", tok_url, err)
|
||
# Standard heuristic for mixed code/russian/english: ~3.5 chars per token
|
||
est_tokens = max(1, int(len(text) / 3.5))
|
||
return TokenCountResult(
|
||
tokens_count=est_tokens,
|
||
is_estimated=True,
|
||
method="char_heuristic",
|
||
)
|
||
|
||
# -------------------------------------------------------------
|
||
# P0-6: Semantic Task Splitting
|
||
# -------------------------------------------------------------
|
||
def calculate_effective_prompt_limit(
|
||
self,
|
||
server_n_ctx: int,
|
||
expected_response_tokens: int = DEFAULT_RESPONSE_MARGIN_TOKENS,
|
||
safety_margin_tokens: int = DEFAULT_SAFETY_MARGIN_TOKENS,
|
||
model_name: Optional[str] = None,
|
||
) -> int:
|
||
"""Calculate safe prompt limit considering response budget, safety buffer, and past memory."""
|
||
base_limit = max(1024, server_n_ctx - expected_response_tokens - safety_margin_tokens)
|
||
|
||
# Check if memory has a smaller known safe working volume
|
||
if model_name:
|
||
rec = self.get_model_memory(model_name)
|
||
if rec and rec.safe_chunk_tokens > 0 and rec.safe_chunk_tokens < base_limit:
|
||
return rec.safe_chunk_tokens
|
||
return base_limit
|
||
|
||
def split_task_semantically(
|
||
self,
|
||
task_text: str,
|
||
max_chunk_tokens: int,
|
||
prompt_template: str = "",
|
||
) -> List[str]:
|
||
"""Split a code or textual task across semantic boundaries (file, class, function, markdown).
|
||
|
||
If the entire text fits, returns [task_text].
|
||
If an individual unit is indivisible and exceeds limit, raises IndivisibleTaskError.
|
||
"""
|
||
template_tokens = self.count_tokens(prompt_template).tokens_count if prompt_template else 0
|
||
usable_budget = max(1, max_chunk_tokens - template_tokens)
|
||
|
||
full_count = self.count_tokens(task_text).tokens_count
|
||
if full_count <= usable_budget:
|
||
return [task_text]
|
||
|
||
# Semantic splitting strategy:
|
||
# Step 1: Detect File Boundaries (e.g. diffs, markdown files, --- file: ...)
|
||
file_splits = re.split(r"(?=(?:^|\n)\s*(?:diff --git|--- [a-zA-Z0-9_/.-]+|### File:|```[a-zA-Z0-9_-]+\s*# [a-zA-Z0-9_/.-]+))", task_text)
|
||
file_splits = [s for s in file_splits if s.strip()]
|
||
|
||
if len(file_splits) > 1 and all(self.count_tokens(f).tokens_count <= usable_budget for f in file_splits):
|
||
return self._pack_chunks(file_splits, usable_budget)
|
||
|
||
# Step 2: Detect Code Function/Class Boundaries
|
||
# Match class/def/function/sections (including indented methods)
|
||
code_units = []
|
||
for segment in (file_splits if len(file_splits) > 1 else [task_text]):
|
||
seg_tokens = self.count_tokens(segment).tokens_count
|
||
if seg_tokens <= usable_budget and len(file_splits) > 1:
|
||
code_units.append(segment)
|
||
else:
|
||
sub_splits = re.split(r"(?=(?:^|\n)\s*(?:class\s+[A-Za-z0-9_]+|def\s+[A-Za-z0-9_]+|async\s+def\s+[A-Za-z0-9_]+|function\s+[A-Za-z0-9_]+|##+\s+))", segment)
|
||
sub_splits = [s for s in sub_splits if s.strip()]
|
||
if len(sub_splits) > 1:
|
||
for sub in sub_splits:
|
||
if self.count_tokens(sub).tokens_count > usable_budget:
|
||
para_splits = re.split(r"(?=\n\n+)", sub)
|
||
para_splits = [p for p in para_splits if p.strip()]
|
||
for p in para_splits:
|
||
if self.count_tokens(p).tokens_count > usable_budget:
|
||
raise IndivisibleTaskError(
|
||
f"Неделимый фрагмент ({self.count_tokens(p).tokens_count} токенов) превышает лимит ({usable_budget} токенов)."
|
||
)
|
||
code_units.append(p)
|
||
else:
|
||
code_units.append(sub)
|
||
else:
|
||
para_splits = re.split(r"(?=\n\n+)", segment)
|
||
para_splits = [p for p in para_splits if p.strip()]
|
||
for p in para_splits:
|
||
if self.count_tokens(p).tokens_count > usable_budget:
|
||
raise IndivisibleTaskError(
|
||
f"Неделимый фрагмент ({self.count_tokens(p).tokens_count} токенов) превышает лимит ({usable_budget} токенов)."
|
||
)
|
||
code_units.append(p)
|
||
|
||
return self._pack_chunks(code_units, usable_budget)
|
||
|
||
def _pack_chunks(self, units: List[str], max_tokens: int) -> List[str]:
|
||
"""Greedily pack atomic units into contiguous chunks up to max_tokens."""
|
||
chunks: List[str] = []
|
||
current_chunk: List[str] = []
|
||
current_tokens = 0
|
||
|
||
for unit in units:
|
||
unit_tokens = self.count_tokens(unit).tokens_count
|
||
if current_chunk and (current_tokens + unit_tokens > max_tokens):
|
||
chunks.append("".join(current_chunk))
|
||
current_chunk = [unit]
|
||
current_tokens = unit_tokens
|
||
else:
|
||
current_chunk.append(unit)
|
||
current_tokens += unit_tokens
|
||
|
||
if current_chunk:
|
||
chunks.append("".join(current_chunk))
|
||
return chunks
|
||
|
||
# -------------------------------------------------------------
|
||
# P0-7: Outcome & Reasoning-Exhaustion Detection
|
||
# -------------------------------------------------------------
|
||
def detect_outcome(
|
||
self,
|
||
response_data: Optional[Dict[str, Any]],
|
||
error: Optional[Exception] = None,
|
||
elapsed_sec: float = 0.0,
|
||
timeout_threshold_sec: float = 180.0,
|
||
) -> Tuple[SupervisorOutcome, str]:
|
||
"""Classify generation outcome distinguishing timeout, error, success, and A39 reasoning exhaustion."""
|
||
if error is not None:
|
||
err_msg = str(error).lower()
|
||
if "timeout" in err_msg or "timed out" in err_msg or elapsed_sec >= timeout_threshold_sec:
|
||
return SupervisorOutcome.TIMEOUT, f"Превышен таймаут исполнения ({elapsed_sec:.1f}s >= {timeout_threshold_sec:.1f}s)"
|
||
return SupervisorOutcome.ERROR, f"Ошибка вызова: {error}"
|
||
|
||
if not response_data:
|
||
return SupervisorOutcome.ERROR, "Пустой ответ от сервера"
|
||
|
||
# Check choices / content
|
||
choices = response_data.get("choices", [])
|
||
if not choices:
|
||
return SupervisorOutcome.ERROR, "Отсутствуют варианты ответа (choices empty)"
|
||
|
||
msg = choices[0].get("message", {})
|
||
content = msg.get("content", "")
|
||
reasoning_content = msg.get("reasoning_content", "")
|
||
timings = response_data.get("timings", {})
|
||
predicted_n = timings.get("predicted_n", 0)
|
||
|
||
# A39 Case: Model spent all tokens thinking/looping with 0 actual output content
|
||
if (not content or content.strip() == "") and (predicted_n > 50 or bool(reasoning_content)):
|
||
return SupervisorOutcome.REASONING_EXHAUSTED, (
|
||
f"Кейс A39: потрачено {predicted_n} токенов на рассуждения, но 0 символов полезного ответа. "
|
||
"Требуется отключение thinking через request_options (enable_thinking: false)."
|
||
)
|
||
|
||
if not content or content.strip() == "":
|
||
return SupervisorOutcome.ERROR, "Модель вернула пустой контент"
|
||
|
||
return SupervisorOutcome.SUCCESS, f"Успешно сгенерировано ({len(content)} символов, {predicted_n} токенов за {elapsed_sec:.2f}s)"
|
||
|
||
# -------------------------------------------------------------
|
||
# P0-8: AI-Memory Tracking by GGUF Build Metadata
|
||
# -------------------------------------------------------------
|
||
def _load_all_memories(self) -> Dict[str, Dict[str, Any]]:
|
||
if not self.memory_path.exists():
|
||
return {}
|
||
try:
|
||
with open(self.memory_path, "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except Exception as e:
|
||
logger.warning("Error reading local model memory from %s: %s", self.memory_path, e)
|
||
return {}
|
||
|
||
def get_model_memory(self, gguf_name: str) -> Optional[ModelMemoryRecord]:
|
||
"""Fetch historical performance and safe working volume for model."""
|
||
clean_name = self._normalize_gguf_name(gguf_name)
|
||
data = self._load_all_memories()
|
||
rec_data = data.get(clean_name)
|
||
if not rec_data:
|
||
return None
|
||
return ModelMemoryRecord(**rec_data)
|
||
|
||
def record_working_volume(
|
||
self,
|
||
gguf_name: str,
|
||
prompt_tokens: int,
|
||
output_tokens: int,
|
||
outcome: SupervisorOutcome,
|
||
speed_tps: float,
|
||
task_id: str = "general",
|
||
) -> ModelMemoryRecord:
|
||
"""Record successful or failed dispatch to canonical AI-Memory."""
|
||
clean_name = self._normalize_gguf_name(gguf_name)
|
||
data = self._load_all_memories()
|
||
|
||
now_iso = datetime.now(timezone.utc).isoformat()
|
||
current = data.get(clean_name)
|
||
|
||
if current:
|
||
rec = ModelMemoryRecord(**current)
|
||
else:
|
||
rec = ModelMemoryRecord(
|
||
gguf_name=clean_name,
|
||
safe_chunk_tokens=prompt_tokens if outcome == SupervisorOutcome.SUCCESS else 4096,
|
||
max_tested_tokens=prompt_tokens,
|
||
successful_dispatches=0,
|
||
failed_dispatches=0,
|
||
last_working_context=prompt_tokens if outcome == SupervisorOutcome.SUCCESS else 0,
|
||
avg_generation_tps=speed_tps,
|
||
last_updated=now_iso,
|
||
)
|
||
|
||
if outcome == SupervisorOutcome.SUCCESS:
|
||
rec.successful_dispatches += 1
|
||
rec.last_working_context = prompt_tokens
|
||
rec.max_tested_tokens = max(rec.max_tested_tokens, prompt_tokens)
|
||
# Smooth exponential safe chunk adjustment
|
||
if prompt_tokens > rec.safe_chunk_tokens:
|
||
rec.safe_chunk_tokens = prompt_tokens
|
||
if speed_tps > 0:
|
||
rec.avg_generation_tps = round((rec.avg_generation_tps * 0.7) + (speed_tps * 0.3), 2)
|
||
else:
|
||
rec.failed_dispatches += 1
|
||
# If failed at this volume, reduce safe chunk tokens by 20%
|
||
if prompt_tokens >= rec.safe_chunk_tokens and rec.safe_chunk_tokens > 2048:
|
||
rec.safe_chunk_tokens = max(2048, int(prompt_tokens * 0.8))
|
||
|
||
rec.last_updated = now_iso
|
||
rec.history.append({
|
||
"timestamp": now_iso,
|
||
"task_id": task_id,
|
||
"prompt_tokens": prompt_tokens,
|
||
"output_tokens": output_tokens,
|
||
"outcome": outcome.value,
|
||
"speed_tps": round(speed_tps, 2),
|
||
})
|
||
if len(rec.history) > 50:
|
||
rec.history = rec.history[-50:]
|
||
|
||
data[clean_name] = asdict(rec)
|
||
self._save_all_memories(data)
|
||
return rec
|
||
|
||
def _save_all_memories(self, data: Dict[str, Dict[str, Any]]):
|
||
try:
|
||
self.memory_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(self.memory_path, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||
except Exception as e:
|
||
logger.error("Failed to save local model memory to %s: %s", self.memory_path, e)
|
||
|
||
def _normalize_gguf_name(self, name: str) -> str:
|
||
"""Extract clean model canonical identity from path or filename."""
|
||
clean = Path(name).stem
|
||
clean = clean.replace(".gguf", "").replace("-Q4_K_M", "").replace("-Instruct", "").strip()
|
||
return clean or "local-model"
|
||
|
||
# -------------------------------------------------------------
|
||
# P0-1, P0-2, P0-3, P0-4, P0-5: Context Compression Integration
|
||
# -------------------------------------------------------------
|
||
def compress_context_if_needed(
|
||
self,
|
||
messages: List[Dict[str, Any]],
|
||
target_context_limit: int,
|
||
compressor_profile: Optional[Any] = None,
|
||
threshold_percent: float = 75.0,
|
||
keep_recent_messages: int = 3,
|
||
timeout_sec: float = 60.0,
|
||
) -> Tuple[List[Dict[str, Any]], CompressionOutcome]:
|
||
"""Compress old context via ContextCompressor if measured tokens exceed threshold."""
|
||
full_text = "\n\n".join(str(m.get("content", "")) for m in messages if isinstance(m, dict))
|
||
current_token_count = self.count_tokens(full_text).tokens_count
|
||
|
||
new_messages, outcome = self.compressor.compress_messages_if_needed(
|
||
messages=messages,
|
||
target_context_limit=target_context_limit,
|
||
current_token_count=current_token_count,
|
||
compressor_profile=compressor_profile,
|
||
threshold_percent=threshold_percent,
|
||
keep_recent_messages=keep_recent_messages,
|
||
timeout_sec=timeout_sec,
|
||
)
|
||
|
||
# Сжиматель считает итог посимвольно (длина / 3.5): доступа к серверу у
|
||
# него нет. Здесь он есть, и степень сжатия — величина, которую владелец
|
||
# читает как измеренную. Пересчитываем настоящим токенизатором, а если
|
||
# он недоступен, честно помечаем оценкой.
|
||
if outcome.status == "SUCCESS":
|
||
new_text = MESSAGE_SEPARATOR.join(
|
||
str(m.get("content", "")) for m in new_messages if isinstance(m, dict)
|
||
)
|
||
counted = self.count_tokens(new_text)
|
||
outcome.tokens_after = counted.tokens_count
|
||
outcome.tokens_after_is_estimate = counted.is_estimated
|
||
outcome.saved_tokens = max(0, outcome.tokens_before - counted.tokens_count)
|
||
outcome.compression_ratio = round(
|
||
counted.tokens_count / max(1, outcome.tokens_before), 2
|
||
)
|
||
|
||
return new_messages, outcome
|
||
|
||
def get_compression_status(self, compressor_profile: Optional[Any] = None) -> Dict[str, Any]:
|
||
"""Return real-time diagnostic status of context compressor."""
|
||
if not compressor_profile and not os.environ.get("LOCAL_COMPRESSOR_BASE_URL"):
|
||
return {
|
||
"configured": False,
|
||
"status": "unconfigured",
|
||
"display_status": "Н/Д: модель для сжатия не выбрана",
|
||
"profile_id": None,
|
||
"endpoint": None,
|
||
"model": None,
|
||
"history_count": len(self.compressor._history_snapshots),
|
||
"total_saved_tokens": sum(h.saved_tokens for h in self.compressor._history_snapshots if h.status == "SUCCESS"),
|
||
}
|
||
|
||
base_url, model_name, _ = self.compressor.resolve_compressor_endpoint(compressor_profile)
|
||
# Check health of compressor endpoint
|
||
is_healthy = False
|
||
n_ctx = 32768
|
||
props_url = f"{base_url}/props"
|
||
if props_url.endswith("/v1/props"):
|
||
props_url = props_url.replace("/v1/props", "/props")
|
||
|
||
try:
|
||
req = urllib.request.Request(props_url, headers={"User-Agent": "Hermes-CompressorCheck/1.0"})
|
||
with urllib.request.urlopen(req, timeout=2.0) as resp:
|
||
data = json.loads(resp.read().decode("utf-8"))
|
||
gen_settings = data.get("default_generation_settings", {})
|
||
n_ctx = int(gen_settings.get("n_ctx") or data.get("n_ctx") or 32768)
|
||
is_healthy = True
|
||
except Exception:
|
||
is_healthy = False
|
||
|
||
recent = self.compressor.get_compression_history(limit=5)
|
||
return {
|
||
"configured": True,
|
||
"status": "ready" if is_healthy else "offline",
|
||
"display_status": (
|
||
f"🟢 Готов: {model_name} ({base_url}) | Контекст: {n_ctx}"
|
||
if is_healthy
|
||
else f"⚠️ Недоступен: {base_url} (сжатие пропускается, задачи не прерываются)"
|
||
),
|
||
"profile_id": getattr(compressor_profile, "profile_id", None) or "local-compressor",
|
||
"endpoint": base_url,
|
||
"model": model_name,
|
||
"n_ctx": n_ctx,
|
||
"is_healthy": is_healthy,
|
||
"history_count": len(self.compressor._history_snapshots),
|
||
"total_saved_tokens": sum(h.saved_tokens for h in self.compressor._history_snapshots if h.status == "SUCCESS"),
|
||
"recent_compressions": recent,
|
||
}
|
||
|