Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 54 additions & 9 deletions hermes_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -2536,16 +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 m and m not in seen:
seen.add(m)
merged.append(m)
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 normalized in live_set:
continue
merged.append(normalized)
if merged:
_save_ollama_cloud_cache(merged)
return merged
Expand Down
27 changes: 27 additions & 0 deletions tests/hermes_cli/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
180 changes: 180 additions & 0 deletions tests/hermes_cli/test_ollama_cloud_provider.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for Ollama Cloud provider integration."""

import os
import time
import pytest
from unittest.mock import patch, MagicMock

Expand Down Expand Up @@ -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