Merge pull request #5378 from nesquena/rel-5317

Release — repair bare custom-provider models from active config (#5314/#5127/#1855)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-07-01 14:13:37 -07:00
commit d7dcf16009
9 files changed
+374 -37

No files matched your search

+165 -21
View File
@@ -5833,14 +5833,16 @@ def _should_attach_codex_provider_context(model: str, raw_active_provider: str,
)
return False
def _read_profile_model_config(
session, requested_provider: str | None,
) -> tuple[str | None, str | None]:
"""Read model.provider and model.default from the session's profile config.
session,
requested_provider: str | None,
) -> tuple[str | None, str | None, dict | None]:
"""Read model.provider, model.default, and the full profile config dict.
Returns (profile_provider, profile_default_model). Both are None when the
session has no profile or the profile config is unreadable.
Returns (profile_provider, profile_default_model, profile_config_dict).
The first two are None when the session has no profile or the profile config
is unreadable; profile_config_dict is None in the same cases so callers only
pay for one YAML parse.
When the session already has an explicit ``requested_provider``, the profile
``model.provider`` is not returned (first tuple element is None) so profile
@@ -5849,7 +5851,7 @@ def _read_profile_model_config(
provider matches ``requested_provider`` after normalization.
"""
if not getattr(session, "profile", None):
return None, None
return None, None, None
try:
from api.profiles import get_hermes_home_for_profile
@@ -5857,16 +5859,16 @@ def _read_profile_model_config(
_profile_home = get_hermes_home_for_profile(session.profile)
_profile_cfg_path = os.path.join(str(_profile_home), "config.yaml")
if not os.path.isfile(_profile_cfg_path):
return None, None
return None, None, None
import yaml
with open(_profile_cfg_path, encoding="utf-8") as _f:
_pcfg = yaml.safe_load(_f) or {}
if not isinstance(_pcfg, dict):
return None, None
return None, None, None
_model_cfg = _pcfg.get("model") or {}
if not isinstance(_model_cfg, dict):
return None, None
return None, None, _pcfg
_provider = (_model_cfg.get("provider") or "").strip() or None
_default = (_model_cfg.get("default") or "").strip() or None
except Exception:
@@ -5875,15 +5877,132 @@ def _read_profile_model_config(
getattr(session, "profile", None),
exc_info=True,
)
return None, None
return None, None, None
_requested = _clean_session_model_provider(requested_provider)
if _requested:
_profile_prov = _clean_session_model_provider(_provider)
if _profile_prov != _requested:
return None, None
return None, _default
return _provider, _default
return None, None, _pcfg
return None, _default, _pcfg
return _provider, _default, _pcfg
def _load_profile_config_dict(session) -> dict | None:
"""Load the session profile's config.yaml as a dict, or None."""
if not getattr(session, "profile", None):
return None
try:
from api.profiles import get_hermes_home_for_profile
_profile_cfg_path = os.path.join(
str(get_hermes_home_for_profile(session.profile)),
"config.yaml",
)
if not os.path.isfile(_profile_cfg_path):
return None
import yaml
with open(_profile_cfg_path, encoding="utf-8") as _f:
_pcfg = yaml.safe_load(_f) or {}
return _pcfg if isinstance(_pcfg, dict) else None
except Exception:
logger.warning(
"profile config read failed for %r",
getattr(session, "profile", None),
exc_info=True,
)
return None
def _ordered_custom_provider_model_ids(entry: dict) -> list[str]:
"""Model ids from a custom_providers entry (default model + dict/list models)."""
ordered: list[str] = []
_cp_model = str(entry.get("model") or "").strip()
if _cp_model:
ordered.append(_cp_model)
_cp_models = entry.get("models")
if isinstance(_cp_models, dict):
for _key in _cp_models.keys():
if isinstance(_key, str):
_kid = _key.strip()
if _kid and _kid not in ordered:
ordered.append(_kid)
elif isinstance(_cp_models, list):
for _item in _cp_models:
if isinstance(_item, str):
_mid = _item.strip()
if _mid and _mid not in ordered:
ordered.append(_mid)
elif isinstance(_item, dict):
_mid = str(
_item.get("id") or _item.get("model") or _item.get("name") or ""
).strip()
if _mid and _mid not in ordered:
ordered.append(_mid)
return ordered
def _repair_bare_custom_provider_model(
bare_model: str,
provider: str | None,
*,
config_obj: dict | None = None,
) -> str | None:
"""Re-qualify a bare model ID using the named custom provider's config (#5314).
Returns the fully namespaced model id when ``bare_model`` matches the suffix
of a registered id on ``custom_providers``; otherwise None. Model ids are
scanned in config declaration order (default ``model`` first, then
``models`` dict keys or list entries) so repair is deterministic when
suffixes collide.
When ``config_obj`` is set (typically the session profile's config.yaml),
only that object's ``custom_providers`` are scanned. Otherwise uses
``get_config()`` for the active global config (not the raw ``cfg`` alias).
"""
try:
model = str(bare_model or "").strip()
prov = _clean_session_model_provider(provider)
if not model or "/" in model or not prov:
return None
if prov != "custom" and not str(prov).startswith("custom:"):
return None
from api.config import (
_custom_provider_entries,
_custom_provider_slug_from_name,
get_config,
)
if isinstance(config_obj, dict):
_entries = _custom_provider_entries(config_obj)
else:
_cfg = get_config()
_entries = _custom_provider_entries(
_cfg if isinstance(_cfg, dict) else None
)
prov_norm = str(prov).strip().lower()
raw_suffix = prov_norm.removeprefix("custom:")
_matching_cp = None
for _entry in _entries:
entry_name = str(_entry.get("name") or "").strip().lower()
slug = _custom_provider_slug_from_name(_entry.get("name"))
if not slug:
continue
if (
prov_norm in {entry_name, slug}
or raw_suffix == slug.removeprefix("custom:")
):
_matching_cp = _entry
break
if not _matching_cp:
return None
for _id in _ordered_custom_provider_model_ids(_matching_cp):
if "/" in _id and _id.rsplit("/", 1)[-1] == model:
return _id
return None
except Exception:
return None
def _resolve_compatible_session_model_state(
@@ -5892,6 +6011,7 @@ def _resolve_compatible_session_model_state(
*,
profile_provider: str | None = None,
profile_default_model: str | None = None,
profile_config: dict | None = None,
explicit_model_pick: bool = False,
prefer_cached_catalog: bool = False,
) -> tuple[str, str | None, bool]:
@@ -5965,6 +6085,15 @@ def _resolve_compatible_session_model_state(
)
):
return _profile_default, requested_provider, True
_repaired_model = _repair_bare_custom_provider_model(
model,
requested_provider,
config_obj=profile_config,
)
if _repaired_model:
return _repaired_model, requested_provider, True
return model, requested_provider, False
# Default (human chat/start) path calls get_available_models() with NO
@@ -6064,6 +6193,14 @@ def _resolve_compatible_session_model_state(
):
return _profile_default, profile_provider, True
_repaired_model = _repair_bare_custom_provider_model(
model,
profile_provider,
config_obj=profile_config,
)
if _repaired_model:
return _repaired_model, profile_provider, True
return model, profile_provider, False
if not model:
@@ -6320,12 +6457,13 @@ def _resolve_effective_session_model_for_display(session) -> str:
"""
original_model = getattr(session, "model", None) or ""
requested_provider = getattr(session, "model_provider", None)
_pp_provider, _pp_default = _read_profile_model_config(session, requested_provider)
_pp_provider, _pp_default, _pp_cfg = _read_profile_model_config(session, requested_provider)
effective_model, _provider, _changed = _resolve_compatible_session_model_state(
original_model or None,
requested_provider,
profile_provider=_pp_provider,
profile_default_model=_pp_default,
profile_config=_pp_cfg,
# GET /api/session is a hot, side-effect-free per-tab/per-poll path.
# It must never pay the cold live provider-catalog rebuild (a
# botocore IMDS probe that cannot resolve on a non-AWS / WSL / corp
@@ -6343,12 +6481,13 @@ def _resolve_effective_session_model_for_display(session) -> str:
def _resolve_effective_session_model_provider_for_display(session) -> str | None:
original_model = getattr(session, "model", None) or ""
requested_provider = getattr(session, "model_provider", None)
_pp_provider, _pp_default = _read_profile_model_config(session, requested_provider)
_pp_provider, _pp_default, _pp_cfg = _read_profile_model_config(session, requested_provider)
_model, provider, _changed = _resolve_compatible_session_model_state(
original_model or None,
requested_provider,
profile_provider=_pp_provider,
profile_default_model=_pp_default,
profile_config=_pp_cfg,
# See _resolve_effective_session_model_for_display: same hot
# side-effect-free GET /api/session path; must not trigger the cold
# live rebuild. prefer_cached_catalog resolves from warm/disk cache
@@ -18833,12 +18972,13 @@ def start_session_turn(
# background task before its first human turn has an empty s.model, and
# without the profile defaults the resolver would fall back to the global
# DEFAULT_MODEL instead of the profile's configured default (greptile flag).
_pp_provider, _pp_default = _read_profile_model_config(s, requested_provider)
_pp_provider, _pp_default, _pp_cfg = _read_profile_model_config(s, requested_provider)
model, model_provider, normalized_model = _resolve_compatible_session_model_state(
requested_model,
requested_provider,
profile_provider=_pp_provider,
profile_default_model=_pp_default,
profile_config=_pp_cfg,
prefer_cached_catalog=True,
)
resp = _start_run(
@@ -19010,12 +19150,13 @@ def _handle_goal_command(handler, body):
if "model_provider" in body
else getattr(s, "model_provider", None)
)
_pp_provider, _pp_default = _read_profile_model_config(s, requested_provider)
_pp_provider, _pp_default, _pp_cfg = _read_profile_model_config(s, requested_provider)
model, model_provider, normalized_model = _resolve_compatible_session_model_state(
requested_model,
requested_provider,
profile_provider=_pp_provider,
profile_default_model=_pp_default,
profile_config=_pp_cfg,
)
previous_goal_state = goal_state_snapshot(s.session_id, profile_home=profile_home)
@@ -19061,12 +19202,13 @@ def _handle_goal_command(handler, body):
if "model_provider" in body
else getattr(s, "model_provider", None)
)
_pp_provider, _pp_default = _read_profile_model_config(s, requested_provider)
_pp_provider, _pp_default, _pp_cfg = _read_profile_model_config(s, requested_provider)
model, model_provider, normalized_model = _resolve_compatible_session_model_state(
requested_model,
requested_provider,
profile_provider=_pp_provider,
profile_default_model=_pp_default,
profile_config=_pp_cfg,
)
stream_response = _start_chat_stream_for_session(
s,
@@ -19206,7 +19348,7 @@ def _handle_chat_start(handler, body, diag=None):
if "model_provider" in body
else getattr(s, "model_provider", None)
)
_pp_provider, _pp_default = _read_profile_model_config(s, requested_provider)
_pp_provider, _pp_default, _pp_cfg = _read_profile_model_config(s, requested_provider)
explicit_model_pick = bool(body.get("explicit_model_pick"))
moa_config = None
if body.get("moa_config"):
@@ -19224,6 +19366,7 @@ def _handle_chat_start(handler, body, diag=None):
requested_provider,
profile_provider=_pp_provider,
profile_default_model=_pp_default,
profile_config=_pp_cfg,
explicit_model_pick=explicit_model_pick,
)
# NOTE: runtime-adapter selection is delegated to _start_run (shared
@@ -19322,12 +19465,13 @@ def _handle_chat_sync(handler, body):
_sync_requested_provider = (
body.get("model_provider") if "model_provider" in body else getattr(s, "model_provider", None)
)
_pp_provider, _pp_default = _read_profile_model_config(s, _sync_requested_provider)
_pp_provider, _pp_default, _pp_cfg = _read_profile_model_config(s, _sync_requested_provider)
model, model_provider = _resolve_compatible_session_model_state(
body.get("model") or s.model,
_sync_requested_provider,
profile_provider=_pp_provider,
profile_default_model=_pp_default,
profile_config=_pp_cfg,
)[:2]
s.model = model
s.model_provider = model_provider
@@ -269,7 +269,12 @@ class TestFastPathSourceShape:
"""The fast-path return must come BEFORE the catalog lookup."""
src = _read("api/routes.py")
idx = src.find("def _resolve_compatible_session_model_state(")
body = src[idx:idx + 6000]
# Helper grew (profile_config, custom repair); 6k window no longer reaches
# the slow-path catalog call — use a bounded slice through the next def.
body = src[idx:idx + 12000]
next_def = body.find("\ndef ", 100)
if next_def != -1:
body = body[:next_def]
fast_path_idx = body.find("if model and requested_provider:")
catalog_idx = body.find("catalog = get_available_models()")
assert fast_path_idx != -1 and catalog_idx != -1
@@ -283,14 +283,14 @@ class TestReadProfileModelConfig:
"""Tests for the _read_profile_model_config helper."""
def test_returns_none_when_no_profile(self):
"""No session profile returns (None, None)."""
"""No session profile returns (None, None, None)."""
from api.routes import _read_profile_model_config
class FakeSession:
profile = None
result = _read_profile_model_config(FakeSession(), None)
assert result == (None, None)
assert result == (None, None, None)
def test_returns_none_when_explicit_provider(self):
"""Explicit requested_provider skips profile config read."""
@@ -300,7 +300,7 @@ class TestReadProfileModelConfig:
profile = "work"
result = _read_profile_model_config(FakeSession(), "openai-codex")
assert result == (None, None)
assert result == (None, None, None)
def test_reads_profile_config(self, tmp_path):
"""Reads model.provider and model.default from profile config.yaml."""
@@ -319,10 +319,11 @@ class TestReadProfileModelConfig:
with patch("api.profiles.get_hermes_home_for_profile", return_value=tmp_path):
result = _read_profile_model_config(FakeSession(), None)
assert result == ("anthropic", "claude-sonnet-4.6")
assert result[:2] == ("anthropic", "claude-sonnet-4.6")
assert result[2] == {"model": {"provider": "anthropic", "default": "claude-sonnet-4.6"}}
def test_missing_config_returns_none(self):
"""Missing config.yaml returns (None, None)."""
"""Missing config.yaml returns (None, None, None)."""
from api.routes import _read_profile_model_config
import tempfile
@@ -333,10 +334,10 @@ class TestReadProfileModelConfig:
with patch("api.profiles.get_hermes_home_for_profile", return_value=Path(td)):
result = _read_profile_model_config(FakeSession(), None)
assert result == (None, None)
assert result == (None, None, None)
def test_empty_provider_returns_none(self, tmp_path):
"""Empty model.provider in config returns (None, None)."""
"""Empty model.provider in config returns (None, default, config)."""
from api.routes import _read_profile_model_config
import yaml
@@ -352,7 +353,7 @@ class TestReadProfileModelConfig:
with patch("api.profiles.get_hermes_home_for_profile", return_value=tmp_path):
result = _read_profile_model_config(FakeSession(), None)
assert result == (None, "claude-sonnet-4.6")
assert result == (None, "claude-sonnet-4.6", {"model": {"provider": "", "default": "claude-sonnet-4.6"}})
class TestStreamingWorkerEnrichment:
@@ -59,7 +59,7 @@ def test_chat_start_materializes_cron_session_before_reply(monkeypatch, tmp_path
monkeypatch.setattr(routes, "get_session", fail_get_session)
monkeypatch.setattr(routes, "_get_or_materialize_session", materialize)
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path))
monkeypatch.setattr(routes, "_read_profile_model_config", lambda _s, _provider: (None, None))
monkeypatch.setattr(routes, "_read_profile_model_config", lambda _s, _provider: (None, None, None))
monkeypatch.setattr(
routes,
"_resolve_compatible_session_model_state",
@@ -103,6 +103,73 @@ class TestIssue5127CustomProviderBareSuffixRepair:
assert mock_catalog.call_count == 1
assert result == ("x-ai/grok-composer-2.5-fast", "custom:my-proxy", True)
def test_fast_path_repairs_non_default_bare_model_of_custom_provider(self):
"""Regression: non-default bare model must be dynamically repaired if listed under the custom provider."""
from api.routes import _resolve_compatible_session_model_state
_mock_cfg = {
"custom_providers": [
{
"name": "my-proxy",
"base_url": "https://llm-proxy.ext.ben.io/v1",
"models": {
"x-ai/grok-composer-2.5-fast": {},
"umans/umans-glm-5.2": {},
}
}
]
}
with patch("api.routes.get_available_models") as mock_catalog, \
patch("api.config.cfg", _mock_cfg):
result = _resolve_compatible_session_model_state(
"grok-composer-2.5-fast",
"custom:my-proxy",
profile_provider="custom:my-proxy",
profile_default_model="umans/umans-glm-5.2",
profile_config=_mock_cfg,
prefer_cached_catalog=True,
)
assert mock_catalog.call_count == 0
assert result == ("x-ai/grok-composer-2.5-fast", "custom:my-proxy", True)
def test_slow_path_repairs_non_default_bare_model_of_custom_provider(self):
"""Regression: slow-path must also dynamically repair non-default bare model if listed under custom provider."""
from api.routes import _resolve_compatible_session_model_state
_mock_cfg = {
"custom_providers": [
{
"name": "my-proxy",
"base_url": "https://llm-proxy.ext.ben.io/v1",
"models": {
"x-ai/grok-composer-2.5-fast": {},
"umans/umans-glm-5.2": {},
}
}
]
}
with patch("api.routes.get_available_models") as mock_catalog, \
patch("api.config.cfg", _mock_cfg):
mock_catalog.return_value = {
"active_provider": "openrouter",
"default_model": "openai/gpt-5.5",
"groups": [],
}
result = _resolve_compatible_session_model_state(
"grok-composer-2.5-fast",
None,
profile_provider="custom:my-proxy",
profile_default_model="umans/umans-glm-5.2",
profile_config=_mock_cfg,
prefer_cached_catalog=True,
)
assert mock_catalog.call_count == 1
assert result == ("x-ai/grok-composer-2.5-fast", "custom:my-proxy", True)
def test_slow_path_does_not_rewrite_unrelated_bare_model_for_custom_provider(self):
from api.routes import _resolve_compatible_session_model_state
@@ -145,6 +212,124 @@ class TestIssue5127CustomProviderBareSuffixRepair:
assert result[2] is True
class TestRepairBareCustomProviderModel:
def test_uses_config_order_when_suffixes_collide(self):
from api.routes import _repair_bare_custom_provider_model
custom_cfg = [
{
"name": "llm-proxy",
"model": "org-a/shared-suffix",
"models": {"org-b/shared-suffix": {}},
}
]
with patch("api.config.cfg", {"custom_providers": custom_cfg}):
assert _repair_bare_custom_provider_model(
"shared-suffix", "custom:llm-proxy",
config_obj={"custom_providers": custom_cfg},
) == "org-a/shared-suffix"
def test_repairs_display_name_provider_via_slug(self):
from api.routes import _repair_bare_custom_provider_model
cfg = {
"custom_providers": [
{
"name": "My Proxy",
"models": {"x-ai/grok-composer-2.5-fast": {}},
}
]
}
assert _repair_bare_custom_provider_model(
"grok-composer-2.5-fast",
"custom:my-proxy",
config_obj=cfg,
) == "x-ai/grok-composer-2.5-fast"
def test_repairs_list_form_models_catalog(self):
from api.routes import _repair_bare_custom_provider_model
cfg = {
"custom_providers": [
{
"name": "team",
"models": [
"profile-list/shared",
{"id": "profile-list/other"},
],
}
]
}
assert _repair_bare_custom_provider_model(
"shared",
"custom:team",
config_obj=cfg,
) == "profile-list/shared"
def test_uses_get_config_when_config_obj_is_none(self):
from unittest.mock import patch
from api.routes import _repair_bare_custom_provider_model
cfg = {
"custom_providers": [
{
"name": "team",
"models": {"vendor/shared": {}},
}
]
}
with patch("api.config.get_config") as mock_get_config:
mock_get_config.return_value = cfg
result = _repair_bare_custom_provider_model(
"shared",
"custom:team",
config_obj=None,
)
assert result == "vendor/shared"
mock_get_config.assert_called_once_with()
def test_malformed_custom_provider_name_fails_closed(self):
from api.routes import _repair_bare_custom_provider_model
bad_cfg = {
"custom_providers": [
{"name": None, "models": {"vendor/ok-model": {}}},
{"name": "good-proxy", "models": {"vendor/ok-model": {}}},
]
}
assert _repair_bare_custom_provider_model(
"ok-model",
"custom:good-proxy",
config_obj=bad_cfg,
) == "vendor/ok-model"
def test_profile_config_scoped_not_global_cfg(self):
from api.routes import _repair_bare_custom_provider_model
profile_cfg = {
"custom_providers": [
{
"name": "team",
"models": {"profile-vendor/shared": {}},
}
]
}
global_cfg = {
"custom_providers": [
{
"name": "team",
"models": {"wrong-vendor/shared": {}},
}
]
}
with patch("api.config.cfg", global_cfg):
assert _repair_bare_custom_provider_model(
"shared",
"custom:team",
config_obj=profile_cfg,
) == "profile-vendor/shared"
class TestReadProfileModelConfigWithExplicitProvider:
def test_returns_profile_default_when_session_has_model_provider(self, tmp_path, monkeypatch):
from api.routes import _read_profile_model_config
@@ -164,7 +349,7 @@ class TestReadProfileModelConfigWithExplicitProvider:
lambda _p: str(profile_home),
)
provider, default = _read_profile_model_config(_Session(), "custom:my-proxy")
provider, default, _ = _read_profile_model_config(_Session(), "custom:my-proxy")
assert provider is None
assert default == "x-ai/grok-composer-2.5-fast"
@@ -188,6 +373,6 @@ class TestReadProfileModelConfigWithExplicitProvider:
lambda _p: str(profile_home),
)
provider, default = _read_profile_model_config(_Session(), "custom:other-proxy")
provider, default, _ = _read_profile_model_config(_Session(), "custom:other-proxy")
assert provider is None
assert default is None
+1 -1
View File
@@ -275,7 +275,7 @@ def test_chat_start_refreshes_cli_messages_before_first_webui_turn(monkeypatch,
monkeypatch.setattr(routes, "_get_or_materialize_session", _fake_get_or_materialize_session)
monkeypatch.setattr(routes, "_session_visible_to_active_profile", lambda *args, **kwargs: True)
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda *args, **kwargs: str(tmp_path))
monkeypatch.setattr(routes, "_read_profile_model_config", lambda *args, **kwargs: (None, None))
monkeypatch.setattr(routes, "_read_profile_model_config", lambda *args, **kwargs: (None, None, None))
monkeypatch.setattr(
routes,
"_resolve_compatible_session_model_state",
@@ -188,7 +188,7 @@ def test_chat_start_body_profile_cannot_retag_visible_empty_session_without_acti
monkeypatch.setattr(routes, "_get_or_materialize_session", lambda sid, **_kwargs: empty_visible)
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda *_args, **_kwargs: "/workspace")
monkeypatch.setattr(routes, "_read_profile_model_config", lambda *_args, **_kwargs: (None, None))
monkeypatch.setattr(routes, "_read_profile_model_config", lambda *_args, **_kwargs: (None, None, None))
monkeypatch.setattr(
routes,
"_resolve_compatible_session_model_state",
@@ -50,7 +50,7 @@ def _stub_routes(monkeypatch):
monkeypatch.setattr(
routes_mod,
"_resolve_compatible_session_model_state",
lambda model, provider, profile_provider=None, profile_default_model=None, prefer_cached_catalog=False: (model, provider, model),
lambda model, provider, **kwargs: (model, provider, model),
)
# 4. Block the per-session live-view fan-out (it pokes a real registry).
+3 -1
View File
@@ -123,13 +123,15 @@ def test_wakeup_resolve_passes_prefer_cached_catalog(monkeypatch):
real = routes._resolve_compatible_session_model_state
def _spy(model_id, model_provider=None, *, profile_provider=None,
profile_default_model=None, prefer_cached_catalog=False):
profile_default_model=None, profile_config=None,
prefer_cached_catalog=False, **kwargs):
seen["prefer_cached_catalog"] = prefer_cached_catalog
# The wakeup path now also threads the session's profile model defaults
# through (greptile fix) so a brand-new session with an empty model
# falls back to the profile default, not the global DEFAULT_MODEL.
seen["profile_provider"] = profile_provider
seen["profile_default_model"] = profile_default_model
seen["profile_config"] = profile_config
return ("m", None, False)
monkeypatch.setattr(