Skip to content
5 changes: 4 additions & 1 deletion agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -1359,7 +1359,10 @@ def init_agent(
if _target and _cp_url == _target:
_cp_models = _cp_entry.get("models", {})
if isinstance(_cp_models, dict):
_cp_model_cfg = _cp_models.get(agent.model, {})
_cp_model_cfg = _cp_models.get(agent.model)
if not isinstance(_cp_model_cfg, dict) and "/" in agent.model:
# Mirror the helper's slug fallback (publisher/slug ids).
_cp_model_cfg = _cp_models.get(agent.model.rsplit("/", 1)[1])
if isinstance(_cp_model_cfg, dict):
_cp_ctx = _cp_model_cfg.get("context_length")
if _cp_ctx is not None:
Expand Down
85 changes: 30 additions & 55 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -8547,20 +8547,25 @@ async def _prepare_inbound_message_text(
_msg_cwd = os.environ.get("TERMINAL_CWD", os.path.expanduser("~"))
_msg_runtime = _resolve_runtime_agent_kwargs()
_msg_config_ctx = None
_msg_custom_providers = None
try:
_msg_cfg = _load_gateway_config()
_msg_model_cfg = _msg_cfg.get("model", {})
if isinstance(_msg_model_cfg, dict):
_msg_raw_ctx = _msg_model_cfg.get("context_length")
if _msg_raw_ctx is not None:
_msg_config_ctx = int(_msg_raw_ctx)
if _msg_cfg:
from hermes_cli.config import get_compatible_custom_providers
_msg_custom_providers = get_compatible_custom_providers(_msg_cfg)
except Exception:
pass
_msg_ctx_len = get_model_context_length(
self._model,
base_url=self._base_url or _msg_runtime.get("base_url") or "",
api_key=_msg_runtime.get("api_key") or "",
config_context_length=_msg_config_ctx,
custom_providers=_msg_custom_providers,
)
_ctx_result = await preprocess_context_references_async(
message_text,
Expand Down Expand Up @@ -8946,32 +8951,15 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
except Exception:
pass

# Check custom_providers per-model context_length
# (same fallback as run_agent.py lines 1171-1189).
# Must run after runtime resolution so _hyg_base_url is set.
# Resolve per-model custom_providers context_length via the
# shared slug-tolerant helper (handles LM Studio publisher/slug
# ids). Must run after runtime resolution so _hyg_base_url is set.
if _hyg_config_context_length is None and _hyg_base_url:
try:
try:
from hermes_cli.config import get_compatible_custom_providers as _gw_gcp
_hyg_custom_providers = _gw_gcp(_hyg_data)
except Exception:
_hyg_custom_providers = _hyg_data.get("custom_providers")
if not isinstance(_hyg_custom_providers, list):
_hyg_custom_providers = []
for _cp in _hyg_custom_providers:
if not isinstance(_cp, dict):
continue
_cp_url = (_cp.get("base_url") or "").rstrip("/")
if _cp_url and _cp_url == _hyg_base_url.rstrip("/"):
_cp_models = _cp.get("models", {})
if isinstance(_cp_models, dict):
_cp_model_cfg = _cp_models.get(_hyg_model, {})
if isinstance(_cp_model_cfg, dict):
_cp_ctx = _cp_model_cfg.get("context_length")
if _cp_ctx is not None:
_hyg_config_context_length = int(_cp_ctx)
break
except (TypeError, ValueError):
from hermes_cli.config import get_custom_provider_context_length as _gw_gcpcl
if resolved := _gw_gcpcl(model=_hyg_model, base_url=_hyg_base_url, config=_hyg_data):
_hyg_config_context_length = resolved
except Exception:
pass
except Exception:
pass
Expand Down Expand Up @@ -9752,39 +9740,15 @@ def _format_session_info(self) -> str:
except Exception:
pass

# Also check custom_providers for context_length when top-level model.context_length is not set
# Legacy entry-level override: top-level cp.model + cp.context_length
# (a distinct, documented schema from the per-model models: dict below).
if config_context_length is None and data:
try:
custom_providers = data.get("custom_providers", [])
if custom_providers:
for cp in custom_providers:
if not isinstance(cp, dict):
continue
cp_model = cp.get("model") or ""
cp_models = cp.get("models") or {}
# Match provider model to current model
if cp_model and cp_model == model:
raw_cp_ctx = cp.get("context_length")
if raw_cp_ctx is not None:
try:
config_context_length = int(raw_cp_ctx)
break
except (TypeError, ValueError):
pass
# Also check per-model context_length
if isinstance(cp_models, dict):
model_entry = cp_models.get(model)
if isinstance(model_entry, dict):
model_ctx = model_entry.get("context_length")
else:
model_ctx = model_entry
if model_ctx is not None and isinstance(model_ctx, (int, float)):
try:
config_context_length = int(model_ctx)
break
except (TypeError, ValueError):
pass
except Exception:
for cp in data.get("custom_providers", []) or []:
if isinstance(cp, dict) and cp.get("model") == model and cp.get("context_length") is not None:
config_context_length = int(cp["context_length"])
break
except (TypeError, ValueError):
pass

# Resolve runtime credentials for probing
Expand All @@ -9796,6 +9760,17 @@ def _format_session_info(self) -> str:
except Exception:
pass

# Per-model custom_providers override via the shared slug-tolerant helper.
# Runs after runtime resolution so base_url is populated, and gates on it
# (the old inline loop matched by model name alone, ignoring base_url).
if config_context_length is None and base_url:
try:
from hermes_cli.config import get_custom_provider_context_length
if resolved := get_custom_provider_context_length(model=model, base_url=base_url, custom_providers=custom_provs):
config_context_length = resolved
except Exception:
pass

context_length = get_model_context_length(
model,
base_url=base_url or "",
Expand Down
14 changes: 14 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3720,6 +3720,12 @@ def get_custom_provider_context_length(
if not isinstance(models, dict):
continue
model_cfg = models.get(model)
match = "exact"
if not isinstance(model_cfg, dict) and "/" in model:
# Slug fallback: LM Studio reports "publisher/slug" runtime ids but
# users key custom_providers by the bare slug. Exact match wins first.
model_cfg = models.get(model.rsplit("/", 1)[1])
match = "slug"
if not isinstance(model_cfg, dict):
continue
raw_ctx = model_cfg.get("context_length")
Expand All @@ -3730,7 +3736,15 @@ def get_custom_provider_context_length(
except (TypeError, ValueError):
continue
if ctx > 0:
logger.debug(
"custom_providers context_length resolved: model=%r base_url=%r ctx=%d (%s match)",
model, base_url, ctx, match,
)
return ctx
logger.debug(
"custom_providers context_length miss: model=%r base_url=%r — falling back to probe/default",
model, base_url,
)
return None


Expand Down
67 changes: 67 additions & 0 deletions tests/gateway/test_context_expansion_custom_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""@context budget probe must honor custom_providers per-model context_length.

The ``@file`` reference expansion sizes its injection budget from
``get_model_context_length``. For a slug-keyed custom-provider override
(LM Studio's ``publisher/slug`` ids) the per-model context_length must reach
that probe — otherwise the budget silently falls back to the default. Regression
residual of PR #18844 (a).
"""
from types import SimpleNamespace

import pytest

from gateway.platforms.base import MessageEvent
from gateway.run import GatewayRunner
from gateway.session import SessionSource


@pytest.mark.asyncio
async def test_context_expansion_uses_custom_provider_slug_budget(monkeypatch):
import gateway.run as gateway_run

config = {
"model": {"default": "lmstudio/phi-4", "provider": "custom"},
"custom_providers": [
{
"name": "lmstudio",
"base_url": "http://localhost:1234/v1",
"models": {"phi-4": {"context_length": 1_048_576}},
}
],
}
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: config)
monkeypatch.setattr(
gateway_run,
"_resolve_runtime_agent_kwargs",
lambda: {"provider": "custom", "base_url": "http://localhost:1234/v1", "api_key": "x"},
)

captured = {}

async def fake_preprocess(message_text, *, cwd, context_length, allowed_root):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main resolves the model and endpoint through _resolve_session_agent_runtime rather than GatewayRunner._model/_base_url (gateway/run.py:10633-10645). When salvaging this test, stub that resolver and assert the slug-resolved context reaches the current async preprocessor path.

captured["context_length"] = context_length
return SimpleNamespace(blocked=False, expanded=False, message=message_text, warnings=[])

monkeypatch.setattr(
"agent.context_references.preprocess_context_references_async", fake_preprocess
)

runner = object.__new__(GatewayRunner)
runner._model = "lmstudio/phi-4"
runner._base_url = "http://localhost:1234/v1"
runner.config = SimpleNamespace()
runner.adapters = {}
runner._session_key_for_source = lambda source: "agent:main:telegram:dm:1"

event = MessageEvent(
text="summarise @notes.md please",
source=SessionSource(platform=None, chat_id="1", chat_type="dm", user_id="9"),
message_id="1",
)

await runner._prepare_inbound_message_text(
event=event, source=event.source, history=[]
)

# Budget reflects the slug-keyed 1M override (step-0b), not the 256K default.
assert captured["context_length"] == 1_048_576
103 changes: 103 additions & 0 deletions tests/gateway/test_session_hygiene.py
Original file line number Diff line number Diff line change
Expand Up @@ -852,3 +852,106 @@ def _compress_context(self, messages, *_args, **_kwargs):
assert FakeCompressAgent.last_instance is None, (
"Compression should NOT fire at 12 messages with default hard_limit=400"
)


@pytest.mark.asyncio
async def test_hygiene_threshold_uses_custom_provider_slug_context_length(monkeypatch, tmp_path):
"""Hygiene resolves a slug-keyed custom_providers context_length.

LM Studio runs the model under the prefixed id ``lmstudio/phi-4`` while
the user keys ``custom_providers`` by the bare slug ``phi-4``. The
configured 1M context must be threaded into ``get_model_context_length``
as ``config_context_length`` — not silently dropped to the 64K/256K
fallback (the 0.14.0 regression, #30178).
"""
fake_dotenv = types.ModuleType("dotenv")
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)

gateway_run = importlib.import_module("gateway.run")

config = {
"model": {
"default": "lmstudio/phi-4",
"provider": "custom",
"base_url": "http://localhost:1234/v1",
},
"compression": {"enabled": True},
"custom_providers": [
{
"name": "lmstudio",
"base_url": "http://localhost:1234/v1",
"models": {"phi-4": {"context_length": 1_048_576}},
}
],
}
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: config)

captured = {}

def fake_gmcl(model, **kwargs):
captured["model"] = model
captured["config_context_length"] = kwargs.get("config_context_length")
return 10_000_000 # huge → hygiene never fires; we only want the kwarg

monkeypatch.setattr("agent.model_metadata.get_model_context_length", fake_gmcl)

runner = object.__new__(gateway_run.GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake-token")}
)
runner.adapters = {Platform.TELEGRAM: HygieneCaptureAdapter()}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = SessionEntry(
session_key="agent:main:telegram:group:-1001:17585",
session_id="sess-1",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="group",
)
runner.session_store.load_transcript.return_value = _make_history(6, content_size=400)
runner.session_store.has_any_sessions.return_value = True
runner.session_store.rewrite_transcript = MagicMock()
runner.session_store.append_to_transcript = MagicMock()
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._session_db = None
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._resolve_session_agent_runtime = lambda **_kw: (
"lmstudio/phi-4",
{"provider": "custom", "base_url": "http://localhost:1234/v1", "api_key": "x"},
)
runner._run_agent = AsyncMock(
return_value={
"final_response": "ok",
"messages": [],
"tools": [],
"history_offset": 0,
"last_prompt_tokens": 0,
}
)

monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"})

event = MessageEvent(
text="hello",
source=SessionSource(
platform=Platform.TELEGRAM,
chat_id="-1001",
chat_type="group",
thread_id="17585",
user_id="12345",
),
message_id="1",
)

await runner._handle_message(event)

assert captured["model"] == "lmstudio/phi-4"
assert captured["config_context_length"] == 1_048_576
30 changes: 30 additions & 0 deletions tests/gateway/test_session_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,33 @@ def test_runtime_resolution_failure_doesnt_crash(self, runner, tmp_path):
info = runner._format_session_info()
assert "4K" in info
assert "config" in info

def test_custom_provider_slug_context_length_labeled_config(self, runner, tmp_path):
"""A slug-keyed custom_providers override must surface as '(config)'.

Runtime model is the prefixed 'lmstudio/phi-4'; the override is keyed
by the bare slug 'phi-4'. The displayed number was already correct via
get_model_context_length's step-0b, but the source label wrongly read
'(detected)' until the inline /info lookup routed through the shared
slug-tolerant helper.
"""
config_yaml = (
"model:\n"
" default: lmstudio/phi-4\n"
" provider: custom\n"
" base_url: http://localhost:1234/v1\n"
"custom_providers:\n"
" - name: lmstudio\n"
" base_url: http://localhost:1234/v1\n"
" models:\n"
" phi-4:\n"
" context_length: 1048576\n"
)
p1, p2, p3 = _patch_info(
tmp_path, config_yaml, "lmstudio/phi-4",
{"provider": "custom", "base_url": "http://localhost:1234/v1", "api_key": "k"},
)
with p1, p2, p3:
info = runner._format_session_info()
assert "1.0M" in info
assert "(config)" in info
Loading
Loading