fix(model_switch): section 3 base_url/model/dedup follow-up

On top of the salvaged PR #12505 (Jason/farion1231, which adds dict-format
models: enumeration to both sections), three section-3 refinements from
competing PR #11534 (YangManBOBO):

- accept base_url as canonical (matches Hermes's writer and custom_providers
  entries); keep api/url as fallbacks for legacy/hand-edited configs
- accept singular model as a default_model synonym, matching custom_providers
- add seen_slugs guard so the same provider slug appearing in both
  providers: dict and custom_providers: list emits exactly one picker row
  (providers: dict wins since section 3 runs first)

Two regression tests cover the new behavior. AUTHOR_MAP entry added for
farion1231 so CI doesn't reject the cherry-picked commit.
This commit is contained in:
Teknium 2026-04-19 05:44:44 -07:00 committed by Teknium
parent bca03eab20
commit 5a23f3291a
3 changed files with 95 additions and 2 deletions

View file

@ -1039,9 +1039,23 @@ def list_authenticated_providers(
for ep_name, ep_cfg in user_providers.items():
if not isinstance(ep_cfg, dict):
continue
# Skip if this slug was already emitted (e.g. canonical provider
# with the same name) or will be picked up by section 4.
if ep_name.lower() in seen_slugs:
continue
display_name = ep_cfg.get("name", "") or ep_name
api_url = ep_cfg.get("api", "") or ep_cfg.get("url", "") or ""
default_model = ep_cfg.get("default_model", "")
# ``base_url`` is Hermes's canonical write key (matches
# custom_providers and _save_custom_provider); ``api`` / ``url``
# remain as fallbacks for hand-edited / legacy configs.
api_url = (
ep_cfg.get("base_url", "")
or ep_cfg.get("api", "")
or ep_cfg.get("url", "")
or ""
)
# ``default_model`` is the legacy key; ``model`` matches what
# custom_providers entries use, so accept either.
default_model = ep_cfg.get("default_model", "") or ep_cfg.get("model", "")
# Build models list from both default_model and full models array
models_list = []
@ -1073,6 +1087,7 @@ def list_authenticated_providers(
"source": "user-config",
"api_url": api_url,
})
seen_slugs.add(ep_name.lower())
# --- 4. Saved custom providers from config ---
# Each ``custom_providers`` entry represents one model under a named

View file

@ -177,6 +177,7 @@ AUTHOR_MAP = {
"duerzy@gmail.com": "duerzy",
"emozilla@nousresearch.com": "emozilla",
"fancydirty@gmail.com": "fancydirty",
"farion1231@gmail.com": "farion1231",
"floptopbot33@gmail.com": "flobo3",
"fontana.pedro93@gmail.com": "pefontana",
"francis.x.fitzpatrick@gmail.com": "fxfitz",

View file

@ -227,6 +227,83 @@ def test_list_authenticated_providers_fallback_to_default_only(monkeypatch):
assert user_prov["models"] == ["single-model"]
def test_list_authenticated_providers_accepts_base_url_and_singular_model(monkeypatch):
"""providers: dict entries written in canonical Hermes shape
(``base_url`` + singular ``model``) should resolve the same as the
legacy ``api`` + ``default_model`` shape.
Regression: section 3 previously only read ``api``/``url`` and
``default_model``, so new-shape entries written by Hermes's own writer
surfaced with empty ``api_url`` and no default.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
user_providers = {
"custom": {
"base_url": "http://example.com/v1",
"model": "gpt-5.4",
"models": {
"gpt-5.4": {},
"grok-4.20-beta": {},
"minimax-m2.7": {},
},
}
}
providers = list_authenticated_providers(
current_provider="custom",
user_providers=user_providers,
custom_providers=[],
max_models=50,
)
custom = next((p for p in providers if p["slug"] == "custom"), None)
assert custom is not None
assert custom["api_url"] == "http://example.com/v1"
assert custom["models"] == ["gpt-5.4", "grok-4.20-beta", "minimax-m2.7"]
assert custom["total_models"] == 3
def test_list_authenticated_providers_dedupes_when_user_and_custom_overlap(monkeypatch):
"""When the same slug appears in both ``providers:`` dict and
``custom_providers:`` list, emit exactly one row (providers: dict wins
since it is processed first).
Regression: section 3 previously had no ``seen_slugs`` check, so
overlapping entries produced two picker rows for the same provider.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
current_provider="custom",
user_providers={
"custom": {
"base_url": "http://example.com/v1",
"model": "gpt-5.4",
"models": {
"gpt-5.4": {},
"grok-4.20-beta": {},
},
}
},
custom_providers=[
{
"name": "custom",
"base_url": "http://example.com/v1",
"model": "legacy-only-model",
}
],
max_models=50,
)
matches = [p for p in providers if p["slug"] == "custom"]
assert len(matches) == 1
# providers: dict wins — legacy-only-model is suppressed.
assert matches[0]["models"] == ["gpt-5.4", "grok-4.20-beta"]
# =============================================================================
# Tests for _get_named_custom_provider with providers: dict
# =============================================================================