From 25b5edb1b53ed779b8d5bf7d5150764ecc67a00e Mon Sep 17 00:00:00 2001 From: Pratik Rai Date: Mon, 27 Apr 2026 00:52:13 +0530 Subject: [PATCH 1/2] fix(cli): sanitize Ollama Cloud model IDs by stripping cloud suffixes --- hermes_cli/models.py | 23 ++++++++++++++++++++--- tests/hermes_cli/test_models.py | 27 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 5170bc7ce1ee7..c27a5208f2814 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -2542,10 +2542,27 @@ def fetch_ollama_cloud_models( if m and m not in seen: seen.add(m) merged.append(m) + live_set = set(live_models) for m in mdev_models: - if m and m not in seen: - seen.add(m) - merged.append(m) + if not m or m in seen: + continue + # Strip :cloud / -cloud suffixes from static registry to avoid 400/404 + if m.endswith(":cloud"): + sanitized = m[:-6] + elif m.endswith("-cloud"): + sanitized = m[:-6] + else: + sanitized = m + if not sanitized: + continue + # Discard if the sanitized version already exists in the live API + if sanitized in live_set: + continue + # Also dedupe against other sanitized models.dev entries + if sanitized in seen: + continue + seen.add(sanitized) + merged.append(sanitized) if merged: _save_ollama_cloud_cache(merged) return merged diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index d0201a3e8028d..a442bd16dee46 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -615,3 +615,30 @@ def test_tier_detection_error_defaults_to_paid(self): patch("hermes_cli.models.check_nous_free_tier", side_effect=RuntimeError("boom")), ): assert get_nous_recommended_aux_model(vision=False) == "paid-model" + + +class TestFetchOllamaCloudModels: + """Tests for fetch_ollama_cloud_models — suffix sanitization from static registry.""" + + def test_strips_cloud_suffixes_and_avoids_duplicates(self, tmp_path, monkeypatch): + """Static registry :cloud / -cloud suffixes are stripped; duplicates discarded.""" + from hermes_cli.models import fetch_ollama_cloud_models + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("OLLAMA_API_KEY", "test-key") + + mock_mdev = { + "ollama-cloud": { + "models": { + "kimi-k2.6:cloud": {"tool_call": True}, + "glm-5.1-cloud": {"tool_call": True}, + } + } + } + with patch("hermes_cli.models.fetch_api_models", return_value=["kimi-k2.6"]), \ + patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev): + result = fetch_ollama_cloud_models(force_refresh=True) + + assert result == ["kimi-k2.6", "glm-5.1"] + assert "kimi-k2.6:cloud" not in result + assert "glm-5.1-cloud" not in result From 348015aa2121dbeb89cd42d4aff0cb1351acdae3 Mon Sep 17 00:00:00 2001 From: Pratik Rai Date: Mon, 27 Apr 2026 01:10:29 +0530 Subject: [PATCH 2/2] fix(cli): implement self-healing sanitization for Ollama Cloud model IDs --- hermes_cli/models.py | 72 ++++--- .../hermes_cli/test_ollama_cloud_provider.py | 180 ++++++++++++++++++ 2 files changed, 230 insertions(+), 22 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index c27a5208f2814..8c0ae0336af67 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -2471,6 +2471,11 @@ def _load_ollama_cloud_cache(*, ignore_ttl: bool = False) -> Optional[dict]: models = data.get("models") if not (isinstance(models, list) and models): return None + # Repair legacy caches that may contain :cloud / -cloud suffixes + sanitized = _sanitize_ollama_cloud_model_list(models) + if not sanitized: + return None + data["models"] = sanitized if not ignore_ttl: cached_at = data.get("cached_at", 0) if (time.time() - cached_at) > _OLLAMA_CLOUD_CACHE_TTL: @@ -2492,6 +2497,46 @@ def _save_ollama_cloud_cache(models: list[str]) -> None: pass +def _strip_ollama_cloud_suffix(model_id: str) -> str: + """Remove ``:cloud`` or ``-cloud`` suffixes from models.dev Ollama Cloud IDs. + + models.dev appends these suffixes to distinguish cloud-hosted variants, + but the live Ollama Cloud API does not accept them (returns 400/404). + """ + if model_id.endswith(":cloud"): + return model_id[:-6] + if model_id.endswith("-cloud"): + return model_id[:-6] + return model_id + + +def _sanitize_ollama_cloud_model_list(models: list[str]) -> list[str]: + """Strip ``:cloud`` / ``-cloud`` suffixes and dedupe, preserving order. + + Repairs legacy on-disk caches that may still contain suffixed IDs. + """ + seen: set[str] = set() + result: list[str] = [] + stripped_any = False + for m in models: + if not m or m in seen: + continue + normalized = _strip_ollama_cloud_suffix(m) + if not normalized or normalized in seen: + stripped_any = True + continue + if normalized != m: + stripped_any = True + seen.add(normalized) + result.append(normalized) + if stripped_any: + import logging + logging.getLogger(__name__).debug( + "Stripped :cloud / -cloud suffixes from Ollama Cloud model list" + ) + return result + + def fetch_ollama_cloud_models( api_key: Optional[str] = None, base_url: Optional[str] = None, @@ -2536,33 +2581,16 @@ def fetch_ollama_cloud_models( # 4. Merge: live first, then models.dev additions (deduped, order-preserving) if live_models or mdev_models: - seen: set[str] = set() - merged: list[str] = [] - for m in live_models: - if m and m not in seen: - seen.add(m) - merged.append(m) + merged = _sanitize_ollama_cloud_model_list(live_models) live_set = set(live_models) for m in mdev_models: - if not m or m in seen: - continue - # Strip :cloud / -cloud suffixes from static registry to avoid 400/404 - if m.endswith(":cloud"): - sanitized = m[:-6] - elif m.endswith("-cloud"): - sanitized = m[:-6] - else: - sanitized = m - if not sanitized: + normalized = _strip_ollama_cloud_suffix(m) + if not normalized or normalized in merged: continue # Discard if the sanitized version already exists in the live API - if sanitized in live_set: - continue - # Also dedupe against other sanitized models.dev entries - if sanitized in seen: + if normalized in live_set: continue - seen.add(sanitized) - merged.append(sanitized) + merged.append(normalized) if merged: _save_ollama_cloud_cache(merged) return merged diff --git a/tests/hermes_cli/test_ollama_cloud_provider.py b/tests/hermes_cli/test_ollama_cloud_provider.py index f3702a417e797..dfb033b7bc686 100644 --- a/tests/hermes_cli/test_ollama_cloud_provider.py +++ b/tests/hermes_cli/test_ollama_cloud_provider.py @@ -1,6 +1,7 @@ """Tests for Ollama Cloud provider integration.""" import os +import time import pytest from unittest.mock import patch, MagicMock @@ -408,3 +409,182 @@ def test_aux_model_defined(self): from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS assert "ollama-cloud" in _API_KEY_PROVIDER_AUX_MODELS assert _API_KEY_PROVIDER_AUX_MODELS["ollama-cloud"] == "nemotron-3-nano:30b" + + +# ── Suffix Sanitization (Issue #16179) ── + +class TestStripOllamaCloudSuffix: + """Unit tests for _strip_ollama_cloud_suffix.""" + + def test_strip_suffix_helper(self): + from hermes_cli.models import _strip_ollama_cloud_suffix + assert _strip_ollama_cloud_suffix("kimi-k2.6:cloud") == "kimi-k2.6" + assert _strip_ollama_cloud_suffix("glm-5.1-cloud") == "glm-5.1" + assert _strip_ollama_cloud_suffix("qwen3-coder:480b-cloud") == "qwen3-coder:480b" + assert _strip_ollama_cloud_suffix("llama3.1") == "llama3.1" + assert _strip_ollama_cloud_suffix(":cloud") == "" + assert _strip_ollama_cloud_suffix("") == "" + + +class TestOllamaCloudSuffixSanitization: + """Integration tests for suffix stripping in fetch_ollama_cloud_models.""" + + def test_strips_colon_cloud_suffix(self, tmp_path, monkeypatch): + from hermes_cli.models import fetch_ollama_cloud_models + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("OLLAMA_API_KEY", raising=False) + + mock_mdev = { + "ollama-cloud": { + "models": { + "kimi-k2.6:cloud": {"tool_call": True}, + } + } + } + with patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev): + result = fetch_ollama_cloud_models(force_refresh=True) + + assert result == ["kimi-k2.6"] + assert "kimi-k2.6:cloud" not in result + + def test_strips_dash_cloud_suffix(self, tmp_path, monkeypatch): + from hermes_cli.models import fetch_ollama_cloud_models + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("OLLAMA_API_KEY", raising=False) + + mock_mdev = { + "ollama-cloud": { + "models": { + "glm-5.1-cloud": {"tool_call": True}, + } + } + } + with patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev): + result = fetch_ollama_cloud_models(force_refresh=True) + + assert result == ["glm-5.1"] + assert "glm-5.1-cloud" not in result + + def test_unsuffixed_model_id_unchanged(self, tmp_path, monkeypatch): + from hermes_cli.models import fetch_ollama_cloud_models + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("OLLAMA_API_KEY", raising=False) + + mock_mdev = { + "ollama-cloud": { + "models": { + "qwen3.5:397b": {"tool_call": True}, + } + } + } + with patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev): + result = fetch_ollama_cloud_models(force_refresh=True) + + assert result == ["qwen3.5:397b"] + + def test_no_duplicate_when_live_clean_and_mdev_suffixed(self, tmp_path, monkeypatch): + """Live API returns clean IDs; models.dev returns suffixed IDs for same models. + + The suffixed versions must be stripped and deduplicated so only the + clean live ID remains. + """ + from hermes_cli.models import fetch_ollama_cloud_models + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("OLLAMA_API_KEY", "test-key") + + mock_mdev = { + "ollama-cloud": { + "models": { + "kimi-k2.6:cloud": {"tool_call": True}, + "glm-5.1-cloud": {"tool_call": True}, + "qwen3-coder:480b-cloud": {"tool_call": True}, + } + } + } + with patch("hermes_cli.models.fetch_api_models", return_value=["kimi-k2.6", "glm-5.1"]), \ + patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev): + result = fetch_ollama_cloud_models(force_refresh=True) + + assert result == ["kimi-k2.6", "glm-5.1", "qwen3-coder:480b"] + assert "kimi-k2.6:cloud" not in result + assert "glm-5.1-cloud" not in result + assert "qwen3-coder:480b-cloud" not in result + + def test_dedupes_multiple_suffixed_variants(self, tmp_path, monkeypatch): + """Both :cloud and -cloud variants of the same base ID must collapse.""" + from hermes_cli.models import fetch_ollama_cloud_models + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("OLLAMA_API_KEY", raising=False) + + mock_mdev = { + "ollama-cloud": { + "models": { + "kimi-k2.6:cloud": {"tool_call": True}, + "kimi-k2.6-cloud": {"tool_call": True}, + "glm-5.1-cloud": {"tool_call": True}, + } + } + } + with patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev): + result = fetch_ollama_cloud_models(force_refresh=True) + + assert result.count("kimi-k2.6") == 1 + assert result == ["kimi-k2.6", "glm-5.1"] + + def test_skips_empty_sanitized_result(self, tmp_path, monkeypatch): + """A raw :cloud suffix (empty after strip) must not leak into results.""" + from hermes_cli.models import fetch_ollama_cloud_models + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("OLLAMA_API_KEY", raising=False) + + mock_mdev = { + "ollama-cloud": { + "models": { + ":cloud": {"tool_call": True}, + "-cloud": {"tool_call": True}, + "glm-5.1-cloud": {"tool_call": True}, + } + } + } + with patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev): + result = fetch_ollama_cloud_models(force_refresh=True) + + assert "" not in result + assert result == ["glm-5.1"] + + def test_repairs_legacy_cached_suffixed_models(self, tmp_path, monkeypatch): + """Existing on-disk caches with :cloud / -cloud suffixes are repaired on read.""" + import json + from hermes_cli.models import ( + fetch_ollama_cloud_models, + _ollama_cloud_cache_path, + ) + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("OLLAMA_API_KEY", "test-key") + + # Pre-populate a cache with bad suffixed IDs (simulating pre-fix state) + cache_path = _ollama_cloud_cache_path() + cache_path.parent.mkdir(parents=True, exist_ok=True) + with open(cache_path, "w") as f: + json.dump( + { + "models": ["kimi-k2.6:cloud", "glm-5.1-cloud", "qwen3.5:397b"], + "cached_at": time.time(), + }, + f, + ) + + with patch("hermes_cli.models.fetch_api_models", return_value=[]), \ + patch("agent.models_dev.fetch_models_dev", return_value={}): + result = fetch_ollama_cloud_models(force_refresh=False) + + assert result == ["kimi-k2.6", "glm-5.1", "qwen3.5:397b"] + assert "kimi-k2.6:cloud" not in result + assert "glm-5.1-cloud" not in result