{"repo":"NousResearch/hermes-agent","public_id":"3191","display_label":"Ollama reasoning/thinking control and propagation fixes","summary":"These issues and pull requests address passing reasoning_effort or think=false to Ollama and related custom providers to suppress or control thinking/reasoning mode. A recurring concern is that reasoning settings were silently ignored, causing models to run in unintended reasoning modes.","subsystem":"agent","counts":{"members":11,"open_issues":2,"open_issues_distinct":2,"open_prs":1,"closed_issues":2,"merged_prs":1,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":10782},"resolution":{"state":"partially_resolved","by":"pr-10782"},"freshness":{"state":"stale","last_verified_at":"2026-04-16T09:22:09+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1650909090909091},"fix_edges":[{"issue_number":3191,"pr_number":10782,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":3926,"pr_number":10782,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":5059,"pr_number":10782,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":10711,"pr_number":10782,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":3191,"title":"[Feature]: Pass Ollama think: false parameter when reasoning_effort: none is set for custom/Ollama providers","url":null,"state":"closed"},{"kind":"issue","number":6152,"title":"[Feature]: Pass `think: false` to Ollama for non-reasoning models","url":null,"state":"open"},{"kind":"issue","number":25758,"title":"[Bug]: agent.reasoning_effort: none silently ignored on Ollama — main agent stuck in medium mode, bg-review fork can spiral (up to 65k tokens / 28 min)","url":null,"state":"closed"},{"kind":"issue","number":46131,"title":"Fix: Ollama reasoning models return empty content — send reasoning_effort to disable thinking","url":null,"state":"open"},{"kind":"pr","number":3197,"title":"feat(ollama): pass think=false to custom providers when reasoning_effort is none","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10782","number":10782},{"kind":"pr","value":"#3191","number":3191}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":10782,"title":"feat: Ollama improvements — Cloud provider, GLM continuation, think=false, surrogate sanitization, /v1 hint","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":[".env.example","agent/auxiliary_client.py","agent/model_metadata.py","agent/models_dev.py","cli-config.yaml.example","hermes_cli/auth.py","hermes_cli/config.py","hermes_cli/main.py","hermes_cli/model_normalize.py","hermes_cli/models.py","hermes_cli/providers.py","run_agent.py","tests/hermes_cli/test_ollama_cloud_provider.py","tests/run_agent/test_run_agent.py"],"fix_hunks":[{"file":".env.example","hunks":["@@ -24,6 +24,15 @@\n # Optional base URL override (default: Google's OpenAI-compatible endpoint)\n # GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai\n \n+# =============================================================================\n+# LLM PROVIDER (Ollama Cloud)\n+# =============================================================================\n+# Cloud-hosted open models via Ollama's OpenAI-compatible endpoint.\n+# Get your key at: https://ollama.com/settings\n+# OLLAMA_API_KEY=[redacted:secret-assignment]\n+# Optional base URL override (default: https://ollama.com/v1)\n+# OLLAMA_BASE_URL=https://ollama.com/v1\n+\n # =============================================================================\n # LLM PROVIDER (z.ai / GLM)\n # ============================================================================="]},{"file":"agent/auxiliary_client.py","hunks":["@@ -104,6 +104,7 @@ def _normalize_aux_provider(provider: Optional[str]) -> str:\n     \"opencode-zen\": \"gemini-3-flash\",\n     \"opencode-go\": \"glm-5\",\n     \"kilocode\": \"google/gemini-3-flash-preview\",\n+    \"ollama-cloud\": \"nemotron-3-nano:30b\",\n }\n \n # Vision-specific model overrides for direct providers."]},{"file":"agent/model_metadata.py","hunks":["@@ -23,7 +23,7 @@\n # are preserved so the full model name reaches cache lookups and server queries.\n _PROVIDER_PREFIXES: frozenset[str] = frozenset({\n     \"openrouter\", \"nous\", \"openai-codex\", \"copilot\", \"copilot-acp\",\n-    \"gemini\", \"zai\", \"kimi-coding\", \"kimi-coding-cn\", \"minimax\", \"minimax-cn\", \"anthropic\", \"deepseek\",\n+    \"gemini\", \"ollama-cloud\", \"zai\", \"kimi-coding\", \"kimi-coding-cn\", \"minimax\", \"minimax-cn\", \"anthropic\", \"deepseek\",\n     \"opencode-zen\", \"opencode-go\", \"ai-gateway\", \"kilocode\", \"alibaba\",\n     \"qwen-oauth\",\n     \"xiaomi\",","@@ -33,6 +33,7 @@\n     \"google\", \"google-gemini\", \"google-ai-studio\",\n     \"glm\", \"z-ai\", \"z.ai\", \"zhipu\", \"github\", \"github-copilot\",\n     \"github-models\", \"kimi\", \"moonshot\", \"kimi-cn\", \"moonshot-cn\", \"claude\", \"deep-seek\",\n+    \"ollama\",\n     \"opencode\", \"zen\", \"go\", \"vercel\", \"kilo\", \"dashscope\", \"aliyun\", \"qwen\",\n     \"mimo\", \"xiaomi-mimo\",\n     \"arcee-ai\", \"arceeai\",","@@ -239,6 +240,7 @@ def _is_custom_endpoint(base_url: str) -> bool:\n     \"api.x.ai\": \"xai\",\n     \"api.xiaomimimo.com\": \"xiaomi\",\n     \"xiaomimimo.com\": \"xiaomi\",\n+    \"ollama.com\": \"ollama-cloud\",\n }\n \n "]},{"file":"agent/models_dev.py","hunks":["@@ -169,6 +169,7 @@ class ProviderInfo:\n     \"togetherai\": \"togetherai\",\n     \"perplexity\": \"perplexity\",\n     \"cohere\": \"cohere\",\n+    \"ollama-cloud\": \"ollama-cloud\",\n }\n \n # Reverse mapping: models.dev → Hermes (built lazily)"]},{"file":"cli-config.yaml.example","hunks":["@@ -26,6 +26,7 @@ model:\n   #   \"huggingface\"  - Hugging Face Inference (requires: HF_TOKEN)\n   #   \"xiaomi\"       - Xiaomi MiMo (requires: XIAOMI_API_KEY)\n   #   \"arcee\"        - Arcee AI Trinity models (requires: ARCEEAI_API_KEY)\n+  #   \"ollama-cloud\" - Ollama Cloud (requires: OLLAMA_API_KEY — https://ollama.com/settings)\n   #   \"kilocode\"     - KiloCode gateway (requires: KILOCODE_API_KEY)\n   #   \"ai-gateway\"   - Vercel AI Gateway (requires: AI_GATEWAY_API_KEY)\n   #","@@ -37,12 +38,6 @@ model:\n   #     base_url: \"http://localhost:1234/v1\"\n   #   No API key needed — local servers typically ignore auth.\n   #\n-  #   For Ollama Cloud (https://ollama.com/pricing):\n-  #     provider: \"custom\"\n-  #     base_url: \"https://ollama.com/v1\"\n-  #   Set OLLAMA_API_KEY in .env — automatically picked up when base_url\n-  #   points to ollama.com.\n-  #\n   # Can also be overridden with --provider flag or HERMES_INFERENCE_PROVIDER env var.\n   provider: \"auto\"\n   ","@@ -337,6 +332,7 @@ compression:\n #   \"openrouter\" - Force OpenRouter (requires OPENROUTER_API_KEY)\n #   \"nous\"       - Force Nous Portal (requires: hermes login)\n #   \"gemini\"      - Force Google AI Studio direct (requires: GOOGLE_API_KEY or GEMINI_API_KEY)\n+#   \"ollama-cloud\" - Ollama Cloud (requires: OLLAMA_API_KEY)\n #   \"codex\"       - Force Codex OAuth (requires: hermes model → Codex).\n #                  Uses gpt-5.3-codex which supports vision.\n #   \"main\"       - Use your custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY)."]},{"file":"hermes_cli/auth.py","hunks":["@@ -70,6 +70,7 @@\n DEFAULT_QWEN_BASE_URL = \"https://portal.qwen.ai/v1\"\n DEFAULT_GITHUB_MODELS_BASE_URL = \"https://api.githubcopilot.com\"\n DEFAULT_COPILOT_ACP_BASE_URL = \"acp://copilot\"\n+DEFAULT_OLLAMA_CLOUD_BASE_URL = \"https://ollama.com/v1\"\n CODEX_OAUTH_CLIENT_ID = \"app_EMoamEEZ73f0CkXaXp7hrann\"\n CODEX_OAUTH_TOKEN_URL = \"[redacted:secret-assignment]\"\n CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120","@@ -274,6 +275,14 @@ class ProviderConfig:\n         api_key_env_vars=(\"XIAOMI_API_KEY\",),\n         base_url_env_var=\"XIAOMI_BASE_URL\",\n     ),\n+    \"ollama-cloud\": ProviderConfig(\n+        id=\"ollama-cloud\",\n+        name=\"Ollama Cloud\",\n+        auth_type=\"api_key\",\n+        inference_base_url=DEFAULT_OLLAMA_CLOUD_BASE_URL,\n+        api_key_env_vars=(\"OLLAMA_API_KEY\",),\n+        base_url_env_var=\"OLLAMA_BASE_URL\",\n+    ),\n     \"bedrock\": ProviderConfig(\n         id=\"bedrock\",\n         name=\"AWS Bedrock\",","@@ -937,7 +946,8 @@ def resolve_provider(\n         \"kilo\": \"kilocode\", \"kilo-code\": \"kilocode\", \"kilo-gateway\": \"kilocode\",\n         # Local server aliases — route through the generic custom provider\n         \"lmstudio\": \"custom\", \"lm-studio\": \"custom\", \"lm_studio\": \"custom\",\n-        \"ollama\": \"custom\", \"vllm\": \"custom\", \"llamacpp\": \"custom\",\n+        \"ollama\": \"custom\", \"ollama_cloud\": \"ollama-cloud\",\n+        \"vllm\": \"custom\", \"llamacpp\": \"custom\",\n         \"llama.cpp\": \"custom\", \"llama-cpp\": \"custom\",\n     }\n     normalized = _PROVIDER_ALIASES.get(normalized, normalized)"]},{"file":"hermes_cli/config.py","hunks":["@@ -1024,6 +1024,22 @@ def _ensure_hermes_home_managed(home: Path):\n         \"category\": \"provider\",\n         \"advanced\": True,\n     },\n+    \"OLLAMA_API_KEY\": {\n+        \"description\": \"Ollama Cloud API key (ollama.com — cloud-hosted open models)\",\n+        \"prompt\": \"Ollama Cloud API key\",\n+        \"url\": \"https://ollama.com/settings\",\n+        \"password\": True,\n+        \"category\": \"provider\",\n+        \"advanced\": True,\n+    },\n+    \"OLLAMA_BASE_URL\": {\n+        \"description\": \"Ollama Cloud base URL override (default: https://ollama.com/v1)\",\n+        \"prompt\": \"Ollama base URL (leave empty for default)\",\n+        \"url\": None,\n+        \"password\": False,\n+        \"category\": \"provider\",\n+        \"advanced\": True,\n+    },\n     \"XIAOMI_API_KEY\": {\n         \"description\": \"Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)\",\n         \"prompt\": \"Xiaomi MiMo API Key\","]},{"file":"hermes_cli/main.py","hunks":["@@ -1141,7 +1141,7 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:\n         _model_flow_kimi(config, current_model)\n     elif selected_provider == \"bedrock\":\n         _model_flow_bedrock(config, current_model)\n-    elif selected_provider in (\"gemini\", \"deepseek\", \"xai\", \"zai\", \"kimi-coding-cn\", \"minimax\", \"minimax-cn\", \"kilocode\", \"opencode-zen\", \"opencode-go\", \"ai-gateway\", \"alibaba\", \"huggingface\", \"xiaomi\", \"arcee\"):\n+    elif selected_provider in (\"gemini\", \"deepseek\", \"xai\", \"zai\", \"kimi-coding-cn\", \"minimax\", \"minimax-cn\", \"kilocode\", \"opencode-zen\", \"opencode-go\", \"ai-gateway\", \"alibaba\", \"huggingface\", \"xiaomi\", \"arcee\", \"ollama-cloud\"):\n         _model_flow_api_key_provider(config, selected_provider, current_model)\n \n     # ── Post-switch cleanup: clear stale OPENAI_BASE_URL ──────────────","@@ -1568,6 +1568,27 @@ def _model_flow_custom(config):\n \n     effective_key = api_key or current_key\n \n+    # Hint: most local model servers (Ollama, vLLM, llama.cpp) require /v1\n+    # in the base URL for OpenAI-compatible chat completions.  Prompt the\n+    # user if the URL looks like a local server without /v1.\n+    _url_lower = effective_url.rstrip(\"/\").lower()\n+    _looks_local = any(h in _url_lower for h in (\"localhost\", \"127.0.0.1\", \"0.0.0.0\", \":11434\", \":8080\", \":5000\"))\n+    if _looks_local and not _url_lower.endswith(\"/v1\"):\n+        print()\n+        print(f\"  Hint: Did you mean to add /v1 at the end?\")\n+        print(f\"  Most local model servers (Ollama, vLLM, llama.cpp) require it.\")\n+        print(f\"  e.g. {effective_url.rstrip('/')}/v1\")\n+        try:\n+            _add_v1 = input(\"  Add /v1? [Y/n]: \").strip().lower()\n+        except (KeyboardInterrupt, EOFError):\n+            _add_v1 = \"n\"\n+        if _add_v1 in (\"\", \"y\", \"yes\"):\n+            effective_url = effective_url.rstrip(\"/\") + \"/v1\"\n+            if base_url:\n+                base_url = effective_url\n+            print(f\"  Updated URL: {effective_url}\")\n+        print()\n+\n     from hermes_cli.models import probe_api_models\n \n     probe = probe_api_models(effective_key, effective_url)","@@ -2734,34 +2755,43 @@ def _model_flow_api_key_provider(config, provider_id, current_model=\"\"):\n     #   1. models.dev registry (cached, filtered for agentic/tool-capable models)\n     #   2. Curated static fallback list (offline insurance)\n     #   3. Live /models endpoint probe (small providers without models.dev data)\n-    curated = _PROVIDER_MODELS.get(provider_id, [])\n+    #\n+    # Ollama Cloud: dedicated merged discovery (live API + models.dev + disk cache)\n+    if provider_id == \"ollama-cloud\":\n+        from hermes_cli.models import fetch_ollama_cloud_models\n+        api_key_for_probe = [redacted:secret-assignment] or (get_env_value(key_env) if key_env else \"\")\n+        model_list = fetch_ollama_cloud_models(api_key=[redacted:secret-assignment] base_url=effective_base)\n+        if model_list:\n+            print(f\"  Found {len(model_list)} model(s) from Ollama Cloud\")\n+    else:\n+        curated = _PROVIDER_MODELS.get(provider_id, [])\n \n-    # Try models.dev first — returns tool-capable models, filtered for noise\n-    mdev_models: list = []\n-    try:\n-        from agent.models_dev import list_agentic_models\n-        mdev_models = list_agentic_models(provider_id)\n-    except Exception:\n-        pass\n+        # Try models.dev first — returns tool-capable models, filtered for noise\n+        mdev_models: list = []\n+        try:\n+            from agent.models_dev import list_agentic_models\n+            mdev_models = list_agentic_models(provider_id)\n+        except Exception:\n+            pass\n \n-    if mdev_models:\n-        model_list = mdev_models\n-        print(f\"  Found {len(model_list)} model(s) from models.dev registry\")\n-    elif curated and len(curated) >= 8:\n-        # Curated list is substantial — use it directly, skip live probe\n-        model_list = curated\n-        print(f\"  Showing {len(model_list)} curated models — use \\\"Enter custom model name\\\" for others.\")\n-    else:\n-        api_key_for_probe = [redacted:secret-assignment] or (get_env_value(key_env) if key_env else \"\")\n-        live_models = fetch_api_models(api_key_for_probe, effective_base)\n-        if live_models and len(live_models) >= len(curated):\n-            model_list = live_models\n-            print(f\"  Found {len(model_list)} model(s) from {pconfig.name} API\")\n-        else:\n+        if mdev_models:\n+            model_list = mdev_models\n+            print(f\"  Found {len(model_list)} model(s) from models.dev registry\")\n+        elif curated and len(curated) >= 8:\n+            # Curated list is substantial — use it directly, skip live probe\n             model_list = curated\n-            if model_list:\n-                print(f\"  Showing {len(model_list)} curated models — use \\\"Enter custom model name\\\" for others.\")\n-        # else: no defaults either, will fall through to raw input\n+            print(f\"  Showing {len(model_list)} curated models — use \\\"Enter custom model name\\\" for others.\")\n+        else:\n+            api_key_for_probe = [redacted:secret-assignment] or (get_env_value(key_env) if key_env else \"\")\n+            live_models = fetch_api_models(api_key_for_probe, effective_base)\n+            if live_models and len(live_models) >= len(curated):\n+                model_list = live_models\n+                print(f\"  Found {len(model_list)} model(s) from {pconfig.name} API\")\n+            else:\n+                model_list = curated\n+                if model_list:\n+                    print(f\"  Showing {len(model_list)} curated models — use \\\"Enter custom model name\\\" for others.\")\n+            # else: no defaults either, will fall through to raw input\n \n     if provider_id in {\"opencode-zen\", \"opencode-go\"}:\n         model_list = [normalize_opencode_model_id(provider_id, mid) for mid in model_list]","@@ -4860,7 +4890,7 @@ def main():\n     )\n     chat_parser.add_argument(\n         \"--provider\",\n-        choices=[\"auto\", \"openrouter\", \"nous\", \"openai-codex\", \"copilot-acp\", \"copilot\", \"anthropic\", \"gemini\", \"huggingface\", \"zai\", \"kimi-coding\", \"kimi-coding-cn\", \"minimax\", \"minimax-cn\", \"kilocode\", \"xiaomi\", \"arcee\"],\n+        choices=[\"auto\", \"openrouter\", \"nous\", \"openai-codex\", \"copilot-acp\", \"copilot\", \"anthropic\", \"gemini\", \"ollama-cloud\", \"huggingface\", \"zai\", \"kimi-coding\", \"kimi-coding-cn\", \"minimax\", \"minimax-cn\", \"kilocode\", \"xiaomi\", \"arcee\"],\n         default=None,\n         help=\"Inference provider (default: auto)\"\n     )"]},{"file":"hermes_cli/model_normalize.py","hunks":["@@ -96,6 +96,7 @@\n     \"qwen-oauth\",\n     \"xiaomi\",\n     \"arcee\",\n+    \"ollama-cloud\",\n     \"custom\",\n })\n "]},{"file":"hermes_cli/models.py","hunks":["@@ -11,7 +11,9 @@\n import os\n import urllib.request\n import urllib.error\n+import time\n from difflib import get_close_matches\n+from pathlib import Path\n from typing import Any, NamedTuple, Optional\n \n COPILOT_BASE_URL = \"https://api.githubcopilot.com\"","@@ -547,6 +549,7 @@ class ProviderEntry(NamedTuple):\n     ProviderEntry(\"minimax\",        \"MiniMax\",                  \"MiniMax (global direct API)\"),\n     ProviderEntry(\"minimax-cn\",     \"MiniMax (China)\",          \"MiniMax China (domestic direct API)\"),\n     ProviderEntry(\"alibaba\",        \"Alibaba Cloud (DashScope)\",\"Alibaba Cloud / DashScope Coding (Qwen + multi-provider)\"),\n+    ProviderEntry(\"ollama-cloud\",   \"Ollama Cloud\",             \"Ollama Cloud (cloud-hosted open models — ollama.com)\"),\n     ProviderEntry(\"arcee\",          \"Arcee AI\",                 \"Arcee AI (Trinity models — direct API)\"),\n     ProviderEntry(\"kilocode\",       \"Kilo Code\",                \"Kilo Code (Kilo Gateway API)\"),\n     ProviderEntry(\"opencode-zen\",   \"OpenCode Zen\",             \"OpenCode Zen (35+ curated models, pay-as-you-go)\"),","@@ -559,6 +562,7 @@ class ProviderEntry(NamedTuple):\n _PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS}\n _PROVIDER_LABELS[\"custom\"] = \"Custom endpoint\"  # special case: not a named provider\n \n+\n _PROVIDER_ALIASES = {\n     \"glm\": \"zai\",\n     \"z-ai\": \"zai\",","@@ -611,6 +615,8 @@ class ProviderEntry(NamedTuple):\n     \"grok\": \"xai\",\n     \"x-ai\": \"xai\",\n     \"x.ai\": \"xai\",\n+    \"ollama\": \"custom\",  # bare \"ollama\" = local; use \"ollama-cloud\" for cloud\n+    \"ollama_cloud\": \"ollama-cloud\",\n }\n \n ","@@ -1786,6 +1792,125 @@ def fetch_api_models(\n     return probe_api_models(api_key, base_url, timeout=timeout).get(\"models\")\n \n \n+# ---------------------------------------------------------------------------\n+# Ollama Cloud — merged model discovery with disk cache\n+# ---------------------------------------------------------------------------\n+\n+\n+\n+_OLLAMA_CLOUD_CACHE_TTL = 3600  # 1 hour\n+\n+\n+def _ollama_cloud_cache_path() -> Path:\n+    \"\"\"Return the path for the Ollama Cloud model cache.\"\"\"\n+    from hermes_constants import get_hermes_home\n+    return get_hermes_home() / \"ollama_cloud_models_cache.json\"\n+\n+\n+def _load_ollama_cloud_cache(*, ignore_ttl: bool = False) -> Optional[dict]:\n+    \"\"\"Load cached Ollama Cloud models from disk.\n+\n+    Args:\n+        ignore_ttl: If True, return data even if the TTL has expired (stale fallback).\n+    \"\"\"\n+    try:\n+        cache_path = _ollama_cloud_cache_path()\n+        if not cache_path.exists():\n+            return None\n+        with open(cache_path, encoding=\"utf-8\") as f:\n+            data = json.load(f)\n+        if not isinstance(data, dict):\n+            return None\n+        models = data.get(\"models\")\n+        if not (isinstance(models, list) and models):\n+            return None\n+        if not ignore_ttl:\n+            cached_at = data.get(\"cached_at\", 0)\n+            if (time.time() - cached_at) > _OLLAMA_CLOUD_CACHE_TTL:\n+                return None  # stale\n+        return data\n+    except Exception:\n+        pass\n+    return None\n+\n+\n+def _save_ollama_cloud_cache(models: list[str]) -> None:\n+    \"\"\"Persist the merged Ollama Cloud model list to disk.\"\"\"\n+    try:\n+        from utils import atomic_json_write\n+        cache_path = _ollama_cloud_cache_path()\n+        cache_path.parent.mkdir(parents=True, exist_ok=True)\n+        atomic_json_write(cache_path, {\"models\": models, \"cached_at\": time.time()}, indent=None)\n+    except Exception:\n+        pass\n+\n+\n+def fetch_ollama_cloud_models(\n+    api_key: [redacted:secret-assignment] = None,\n+    base_url: Optional[str] = None,\n+    *,\n+    force_refresh: bool = False,\n+) -> list[str]:\n+    \"\"\"Fetch Ollama Cloud models by merging live API + models.dev, with disk cache.\n+\n+    Resolution order:\n+      1. Disk cache (if fresh, < 1 hour, and not force_refresh)\n+      2. Live ``/v1/models`` endpoint (primary — freshest source)\n+      3. models.dev registry (secondary — fills gaps for unlisted models)\n+      4. Merge: live models first, then models.dev additions (deduped)\n+\n+    Returns a list of model IDs (never None — empty list on total failure).\n+    \"\"\"\n+    # 1. Check disk cache\n+    if not force_refresh:\n+        cached = _load_ollama_cloud_cache()\n+        if cached is not None:\n+            return cached[\"models\"]\n+\n+    # 2. Live API probe\n+    if not api_key:\n+        api_key = [redacted:secret-assignment]\"OLLAMA_API_KEY\", \"\")\n+    if not base_url:\n+        base_url = os.getenv(\"OLLAMA_BASE_URL\", \"\") or \"https://ollama.com/v1\"\n+\n+    live_models: list[str] = []\n+    if api_key:\n+        result = fetch_api_models(api_key, base_url, timeout=8.0)\n+        if result:\n+            live_models = result\n+\n+    # 3. models.dev registry\n+    mdev_models: list[str] = []\n+    try:\n+        from agent.models_dev import list_agentic_models\n+        mdev_models = list_agentic_models(\"ollama-cloud\")\n+    except Exception:\n+        pass\n+\n+    # 4. Merge: live first, then models.dev additions (deduped, order-preserving)\n+    if live_models or mdev_models:\n+        seen: set[str] = set()\n+        merged: list[str] = []\n+        for m in live_models:\n+            if m and m not in seen:\n+                seen.add(m)\n+                merged.append(m)\n+        for m in mdev_models:\n+            if m and m not in seen:\n+                seen.add(m)\n+                merged.append(m)\n+        if merged:\n+            _save_ollama_cloud_cache(merged)\n+            return merged\n+\n+    # Total failure — return stale cache if available (ignore TTL)\n+    stale = _load_ollama_cloud_cache(ignore_ttl=True)\n+    if stale is not None:\n+        return stale[\"models\"]\n+\n+    return []\n+\n+\n def validate_requested_model(\n     model_name: str,\n     provider: Optional[str],"]},{"file":"hermes_cli/providers.py","hunks":["@@ -141,6 +141,10 @@ class HermesOverlay:\n         base_url_override=\"https://api.arcee.ai/api/v1\",\n         base_url_env_var=\"ARCEE_BASE_URL\",\n     ),\n+    \"ollama-cloud\": HermesOverlay(\n+        transport=\"openai_chat\",\n+        base_url_env_var=\"OLLAMA_BASE_URL\",\n+    ),\n }\n \n ","@@ -250,7 +254,7 @@ class ProviderDef:\n     \"lmstudio\": \"lmstudio\",\n     \"lm-studio\": \"lmstudio\",\n     \"lm_studio\": \"lmstudio\",\n-    \"ollama\": \"ollama-cloud\",\n+    \"ollama\": \"custom\",  # bare \"ollama\" = local; use \"ollama-cloud\" for cloud\n     \"vllm\": \"local\",\n     \"llamacpp\": \"local\",\n     \"llama.cpp\": \"local\",","@@ -269,6 +273,7 @@ class ProviderDef:\n     \"xiaomi\": \"Xiaomi MiMo\",\n     \"local\": \"Local endpoint\",\n     \"bedrock\": \"AWS Bedrock\",\n+    \"ollama-cloud\": \"Ollama Cloud\",\n }\n \n "]},{"file":"run_agent.py","hunks":["@@ -2103,6 +2103,59 @@ def _strip_think_blocks(self, content: str) -> str:\n         content = re.sub(r'</?(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>\\s*', '', content, flags=re.IGNORECASE)\n         return content\n \n+    @staticmethod\n+    def _has_natural_response_ending(content: str) -> bool:\n+        \"\"\"Heuristic: does visible assistant text look intentionally finished?\"\"\"\n+        if not content:\n+            return False\n+        stripped = content.rstrip()\n+        if not stripped:\n+            return False\n+        if stripped.endswith(\"```\"):\n+            return True\n+        return stripped[-1] in '.!?:)\"\\']}。！？：）】」』》'\n+\n+    def _is_ollama_glm_backend(self) -> bool:\n+        \"\"\"Detect the narrow backend family affected by Ollama/GLM stop misreports.\"\"\"\n+        model_lower = (self.model or \"\").lower()\n+        provider_lower = (self.provider or \"\").lower()\n+        if \"glm\" not in model_lower and provider_lower != \"zai\":\n+            return False\n+        if \"ollama\" in self._base_url_lower or \":11434\" in self._base_url_lower:\n+            return True\n+        return bool(self.base_url and is_local_endpoint(self.base_url))\n+\n+    def _should_treat_stop_as_truncated(\n+        self,\n+        finish_reason: str,\n+        assistant_message,\n+        messages: Optional[list] = None,\n+    ) -> bool:\n+        \"\"\"Detect conservative stop->length misreports for Ollama-hosted GLM models.\"\"\"\n+        if finish_reason != \"stop\" or self.api_mode != \"chat_completions\":\n+            return False\n+        if not self._is_ollama_glm_backend():\n+            return False\n+        if not any(\n+            isinstance(msg, dict) and msg.get(\"role\") == \"tool\"\n+            for msg in (messages or [])\n+        ):\n+            return False\n+        if assistant_message is None or getattr(assistant_message, \"tool_calls\", None):\n+            return False\n+\n+        content = getattr(assistant_message, \"content\", None)\n+        if not isinstance(content, str):\n+            return False\n+\n+        visible_text = self._strip_think_blocks(content).strip()\n+        if not visible_text:\n+            return False\n+        if len(visible_text) < 20 or not re.search(r\"\\s\", visible_text):\n+            return False\n+\n+        return not self._has_natural_response_ending(visible_text)\n+\n     def _looks_like_codex_intermediate_ack(\n         self,\n         user_message: str,","@@ -6635,6 +6688,18 @@ def _build_api_kwargs(self, api_messages: list) -> dict:\n             options[\"num_ctx\"] = self._ollama_num_ctx\n             extra_body[\"options\"] = options\n \n+        # Ollama / custom provider: pass think=false when reasoning is disabled.\n+        # Ollama does not recognise the OpenRouter-style `reasoning` extra_body\n+        # field, so we use its native `think` parameter instead.\n+        # This prevents thinking-capable models (Qwen3, etc.) from generating\n+        # <think> blocks and producing empty-response errors when the user has\n+        # set reasoning_effort: none.\n+        if self.provider == \"custom\" and self.reasoning_config and isinstance(self.reasoning_config, dict):\n+            _effort = (self.reasoning_config.get(\"effort\") or \"\").strip().lower()\n+            _enabled = self.reasoning_config.get(\"enabled\", True)\n+            if _effort == \"none\" or _enabled is False:\n+                extra_body[\"think\"] = False\n+\n         if self._is_qwen_portal():\n             extra_body[\"vl_high_resolution_images\"] = True\n ","@@ -6757,9 +6822,16 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic\n                 except Exception:\n                     pass\n \n+        # Sanitize surrogates from API response — some models (e.g. Kimi/GLM via Ollama)\n+        # can return invalid surrogate code points that crash json.dumps() on persist.\n+        _raw_content = assistant_message.content or \"\"\n+        _san_content = _sanitize_surrogates(_raw_content)\n+        if reasoning_text:\n+            reasoning_text = _sanitize_surrogates(reasoning_text)\n+\n         msg = {\n             \"role\": \"assistant\",\n-            \"content\": assistant_message.content or \"\",\n+            \"content\": _san_content,\n             \"reasoning\": reasoning_text,\n             \"finish_reason\": finish_reason,\n         }","@@ -8640,6 +8712,12 @@ def run_conversation(\n                     new_tcs.append(tc)\n                 am[\"tool_calls\"] = new_tcs\n \n+            # Proactively strip any surrogate characters before the API call.\n+            # Models served via Ollama (Kimi K2.5, GLM-5, Qwen) can return\n+            # lone surrogates (U+D800-U+DFFF) that crash json.dumps() inside\n+            # the OpenAI SDK. Sanitizing here prevents the 3-retry cycle.\n+            _sanitize_messages_surrogates(api_messages)\n+\n             # Calculate approximate request size for logging\n             total_chars = sum(len(str(msg)) for msg in api_messages)\n             approx_tokens = [redacted:secret-assignment]","@@ -9038,6 +9116,17 @@ def _stop_spinner():\n                         finish_reason = stop_reason_map.get(response.stop_reason, \"stop\")\n                     else:\n                         finish_reason = response.choices[0].finish_reason\n+                        assistant_message = response.choices[0].message\n+                        if self._should_treat_stop_as_truncated(\n+                            finish_reason,\n+                            assistant_message,\n+                            messages,\n+                        ):\n+                            self._vprint(\n+                                f\"{self.log_prefix}⚠️  Treating suspicious Ollama/GLM stop response as truncated\",\n+                                force=True,\n+                            )\n+                            finish_reason = \"length\"\n \n                     if finish_reason == \"length\":\n                         self._vprint(f\"{self.log_prefix}⚠️  Response truncated (finish_reason='length') - model hit max output tokens\", force=True)"]},{"file":"tests/hermes_cli/test_ollama_cloud_provider.py","hunks":["@@ -0,0 +1,351 @@\n+\"\"\"Tests for Ollama Cloud provider integration.\"\"\"\n+\n+import os\n+import pytest\n+from unittest.mock import patch, MagicMock\n+\n+from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider, resolve_api_key_provider_credentials\n+from hermes_cli.models import _PROVIDER_MODELS, _PROVIDER_LABELS, _PROVIDER_ALIASES, normalize_provider\n+from hermes_cli.model_normalize import normalize_model_for_provider\n+from agent.model_metadata import _URL_TO_PROVIDER, _PROVIDER_PREFIXES\n+from agent.models_dev import PROVIDER_TO_MODELS_DEV, list_agentic_models\n+\n+\n+# ── Provider Registry ──\n+\n+class TestOllamaCloudProviderRegistry:\n+    def test_ollama_cloud_in_registry(self):\n+        assert \"ollama-cloud\" in PROVIDER_REGISTRY\n+\n+    def test_ollama_cloud_config(self):\n+        pconfig = PROVIDER_REGISTRY[\"ollama-cloud\"]\n+        assert pconfig.id == \"ollama-cloud\"\n+        assert pconfig.name == \"Ollama Cloud\"\n+        assert pconfig.auth_type == \"api_key\"\n+        assert pconfig.inference_base_url == \"https://ollama.com/v1\"\n+\n+    def test_ollama_cloud_env_vars(self):\n+        pconfig = PROVIDER_REGISTRY[\"ollama-cloud\"]\n+        assert pconfig.api_key_env_vars == (\"OLLAMA_API_KEY\",)\n+        assert pconfig.base_url_env_var == \"OLLAMA_BASE_URL\"\n+\n+    def test_ollama_cloud_base_url(self):\n+        assert \"ollama.com\" in PROVIDER_REGISTRY[\"ollama-cloud\"].inference_base_url\n+\n+\n+# ── Provider Aliases ──\n+\n+PROVIDER_ENV_VARS = (\n+    \"OPENROUTER_API_KEY\", \"OPENAI_API_KEY\", \"ANTHROPIC_API_KEY\",\n+    \"GOOGLE_API_KEY\", \"GEMINI_API_KEY\", \"OLLAMA_API_KEY\",\n+    \"GLM_API_KEY\", \"ZAI_API_KEY\", \"KIMI_API_KEY\",\n+    \"MINIMAX_API_KEY\", \"DEEPSEEK_API_KEY\",\n+)\n+\n[redacted:email](autouse=True)\n+def _clean_provider_env(monkeypatch):\n+    for var in PROVIDER_ENV_VARS:\n+        monkeypatch.delenv(var, raising=False)\n+\n+\n+class TestOllamaCloudAliases:\n+    def test_explicit_ollama_cloud(self):\n+        assert resolve_provider(\"ollama-cloud\") == \"ollama-cloud\"\n+\n+    def test_alias_ollama_underscore(self):\n+        \"\"\"ollama_cloud (underscore) is the unambiguous cloud alias.\"\"\"\n+        assert resolve_provider(\"ollama_cloud\") == \"ollama-cloud\"\n+\n+    def test_bare_ollama_stays_local(self):\n+        \"\"\"Bare 'ollama' alias routes to 'custom' (local) — not cloud.\"\"\"\n+        assert resolve_provider(\"ollama\") == \"custom\"\n+\n+    def test_models_py_aliases(self):\n+        assert _PROVIDER_ALIASES.get(\"ollama_cloud\") == \"ollama-cloud\"\n+        # bare \"ollama\" stays local\n+        assert _PROVIDER_ALIASES.get(\"ollama\") == \"custom\"\n+\n+    def test_normalize_provider(self):\n+        assert normalize_provider(\"ollama-cloud\") == \"ollama-cloud\"\n+\n+\n+# ── Auto-detection ──\n+\n+class TestOllamaCloudAutoDetection:\n+    def test_auto_detects_ollama_api_key(self, monkeypatch):\n+        monkeypatch.setenv(\"OLLAMA_API_KEY\", \"test-ollama-key\")\n+        assert resolve_provider(\"auto\") == \"ollama-cloud\"\n+\n+\n+# ── Credential Resolution ──\n+\n+class TestOllamaCloudCredentials:\n+    def test_resolve_with_ollama_api_key(self, monkeypatch):\n+        monkeypatch.setenv(\"OLLAMA_API_KEY\", \"ollama-secret\")\n+        creds = resolve_api_key_provider_credentials(\"ollama-cloud\")\n+        assert creds[\"provider\"] == \"ollama-cloud\"\n+        assert creds[\"api_key\"] == \"ollama-secret\"\n+        assert creds[\"base_url\"] == \"https://ollama.com/v1\"\n+\n+    def test_resolve_with_custom_base_url(self, monkeypatch):\n+        monkeypatch.setenv(\"OLLAMA_API_KEY\", \"key\")\n+        monkeypatch.setenv(\"OLLAMA_BASE_URL\", \"https://custom.ollama/v1\")\n+        creds = resolve_api_key_provider_credentials(\"ollama-cloud\")\n+        assert creds[\"base_url\"] == \"https://custom.ollama/v1\"\n+\n+    def test_runtime_ollama_cloud(self, monkeypatch):\n+        monkeypatch.setenv(\"OLLAMA_API_KEY\", \"ollama-key\")\n+        from hermes_cli.runtime_provider import resolve_runtime_provider\n+        result = resolve_runtime_provider(requested=\"ollama-cloud\")\n+        assert result[\"provider\"] == \"ollama-cloud\"\n+        assert result[\"api_mode\"] == \"chat_completions\"\n+        assert result[\"api_key\"] == \"ollama-key\"\n+        assert result[\"base_url\"] == \"https://ollama.com/v1\"\n+\n+\n+# ── Model Catalog (dynamic — no static list) ──\n+\n+class TestOllamaCloudModelCatalog:\n+    def test_no_static_model_list(self):\n+        \"\"\"Ollama Cloud models are fetched dynamically — no static list to maintain.\"\"\"\n+        assert \"ollama-cloud\" not in _PROVIDER_MODELS\n+\n+    def test_provider_label(self):\n+        assert \"ollama-cloud\" in _PROVIDER_LABELS\n+        assert _PROVIDER_LABELS[\"ollama-cloud\"] == \"Ollama Cloud\"\n+\n+\n+# ── Merged Model Discovery ──\n+\n+class TestOllamaCloudMergedDiscovery:\n+    def test_merges_live_and_models_dev(self, tmp_path, monkeypatch):\n+        \"\"\"Live API models appear first, models.dev additions fill gaps.\"\"\"\n+        from hermes_cli.models import fetch_ollama_cloud_models\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        monkeypatch.setenv(\"OLLAMA_API_KEY\", \"test-key\")\n+\n+        mock_mdev = {\n+            \"ollama-cloud\": {\n+                \"models\": {\n+                    \"glm-5\": {\"tool_call\": True},\n+                    \"kimi-k2.5\": {\"tool_call\": True},\n+                    \"nemotron-3-super\": {\"tool_call\": True},\n+                }\n+            }\n+        }\n+        with patch(\"hermes_cli.models.fetch_api_models\", return_value=[\"qwen3.5:397b\", \"glm-5\"]), \\\n+             patch(\"agent.models_dev.fetch_models_dev\", return_value=mock_mdev):\n+            result = fetch_ollama_cloud_models(force_refresh=True)\n+\n+        # Live models first, then models.dev additions (deduped)\n+        assert result[0] == \"qwen3.5:397b\"  # from live API\n+        assert result[1] == \"glm-5\"          # from live API (also in models.dev)\n+        assert \"kimi-k2.5\" in result         # from models.dev only\n+        assert \"nemotron-3-super\" in result  # from models.dev only\n+        assert result.count(\"glm-5\") == 1    # no duplicates\n+\n+    def test_falls_back_to_models_dev_without_api_key(self, tmp_path, monkeypatch):\n+        \"\"\"Without API key, only models.dev results are returned.\"\"\"\n+        from hermes_cli.models import fetch_ollama_cloud_models\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        monkeypatch.delenv(\"OLLAMA_API_KEY\", raising=False)\n+\n+        mock_mdev = {\n+            \"ollama-cloud\": {\n+                \"models\": {\n+                    \"glm-5\": {\"tool_call\": True},\n+                }\n+            }\n+        }\n+        with patch(\"agent.models_dev.fetch_models_dev\", return_value=mock_mdev):\n+            result = fetch_ollama_cloud_models(force_refresh=True)\n+\n+        assert result == [\"glm-5\"]\n+\n+    def test_uses_disk_cache(self, tmp_path, monkeypatch):\n+        \"\"\"Second call returns cached results without hitting APIs.\"\"\"\n+        from hermes_cli.models import fetch_ollama_cloud_models\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        monkeypatch.setenv(\"OLLAMA_API_KEY\", \"test-key\")\n+\n+        with patch(\"hermes_cli.models.fetch_api_models\", return_value=[\"model-a\"]) as mock_api, \\\n+             patch(\"agent.models_dev.fetch_models_dev\", return_value={}):\n+            first = fetch_ollama_cloud_models(force_refresh=True)\n+            assert first == [\"model-a\"]\n+            assert mock_api.call_count == 1\n+\n+            # Second call — should use disk cache, not call API\n+            second = fetch_ollama_cloud_models()\n+            assert second == [\"model-a\"]\n+            assert mock_api.call_count == 1  # no extra API call\n+\n+    def test_force_refresh_bypasses_cache(self, tmp_path, monkeypatch):\n+        \"\"\"force_refresh=True always hits the API even with fresh cache.\"\"\"\n+        from hermes_cli.models import fetch_ollama_cloud_models\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        monkeypatch.setenv(\"OLLAMA_API_KEY\", \"test-key\")\n+\n+        with patch(\"hermes_cli.models.fetch_api_models\", return_value=[\"model-a\"]) as mock_api, \\\n+             patch(\"agent.models_dev.fetch_models_dev\", return_value={}):\n+            fetch_ollama_cloud_models(force_refresh=True)\n+            fetch_ollama_cloud_models(force_refresh=True)\n+            assert mock_api.call_count == 2\n+\n+    def test_stale_cache_used_on_total_failure(self, tmp_path, monkeypatch):\n+        \"\"\"If both API and models.dev fail, stale cache is returned.\"\"\"\n+        from hermes_cli.models import fetch_ollama_cloud_models, _save_ollama_cloud_cache\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        monkeypatch.setenv(\"OLLAMA_API_KEY\", \"test-key\")\n+\n+        # Pre-populate a stale cache\n+        _save_ollama_cloud_cache([\"stale-model\"])\n+\n+        # Make the cache appear stale by backdating it\n+        import json\n+        cache_path = tmp_path / \"ollama_cloud_models_cache.json\"\n+        with open(cache_path) as f:\n+            data = json.load(f)\n+        data[\"cached_at\"] = 0  # epoch = very stale\n+        with open(cache_path, \"w\") as f:\n+            json.dump(data, f)\n+\n+        with patch(\"hermes_cli.models.fetch_api_models\", return_value=None), \\\n+             patch(\"agent.models_dev.fetch_models_dev\", return_value={}):\n+            result = fetch_ollama_cloud_models(force_refresh=True)\n+\n+        assert result == [\"stale-model\"]\n+\n+    def test_empty_on_total_failure_no_cache(self, tmp_path, monkeypatch):\n+        \"\"\"Returns empty list when everything fails and no cache exists.\"\"\"\n+        from hermes_cli.models import fetch_ollama_cloud_models\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        monkeypatch.delenv(\"OLLAMA_API_KEY\", raising=False)\n+\n+        with patch(\"agent.models_dev.fetch_models_dev\", return_value={}):\n+            result = fetch_ollama_cloud_models(force_refresh=True)\n+\n+        assert result == []\n+\n+\n+# ── Model Normalization ──\n+\n+class TestOllamaCloudModelNormalization:\n+    def test_passthrough_bare_name(self):\n+        \"\"\"Ollama Cloud is a passthrough provider — model names used as-is.\"\"\"\n+        assert normalize_model_for_provider(\"qwen3.5:397b\", \"ollama-cloud\") == \"qwen3.5:397b\"\n+\n+    def test_passthrough_with_tag(self):\n+        assert normalize_model_for_provider(\"cogito-2.1:671b\", \"ollama-cloud\") == \"cogito-2.1:671b\"\n+\n+    def test_passthrough_no_tag(self):\n+        assert normalize_model_for_provider(\"glm-5\", \"ollama-cloud\") == \"glm-5\"\n+\n+\n+# ── URL-to-Provider Mapping ──\n+\n+class TestOllamaCloudUrlMapping:\n+    def test_url_to_provider(self):\n+        assert _URL_TO_PROVIDER.get(\"ollama.com\") == \"ollama-cloud\"\n+\n+    def test_provider_prefix_canonical(self):\n+        assert \"ollama-cloud\" in _PROVIDER_PREFIXES\n+\n+    def test_provider_prefix_alias(self):\n+        assert \"ollama\" in _PROVIDER_PREFIXES\n+\n+\n+# ── models.dev Integration ──\n+\n+class TestOllamaCloudModelsDev:\n+    def test_ollama_cloud_mapped(self):\n+        assert PROVIDER_TO_MODELS_DEV.get(\"ollama-cloud\") == \"ollama-cloud\"\n+\n+    def test_list_agentic_models_with_mock_data(self):\n+        \"\"\"list_agentic_models filters correctly from mock models.dev data.\"\"\"\n+        mock_data = {\n+            \"ollama-cloud\": {\n+                \"models\": {\n+                    \"qwen3.5:397b\": {\"tool_call\": True},\n+                    \"glm-5\": {\"tool_call\": True},\n+                    \"nemotron-3-nano:30b\": {\"tool_call\": True},\n+                    \"some-embedding:latest\": {\"tool_call\": False},\n+                }\n+            }\n+        }\n+        with patch(\"agent.models_dev.fetch_models_dev\", return_value=mock_data):\n+            result = list_agentic_models(\"ollama-cloud\")\n+        assert \"qwen3.5:397b\" in result\n+        assert \"glm-5\" in result\n+        assert \"nemotron-3-nano:30b\" in result\n+        assert \"some-embedding:latest\" not in result  # no tool_call\n+\n+\n+# ── Agent Init (no SyntaxError) ──\n+\n+class TestOllamaCloudAgentInit:\n+    def test_agent_imports_without_error(self):\n+        \"\"\"Verify run_agent.py has no SyntaxError.\"\"\"\n+        import importlib\n+        import run_agent\n+        importlib.reload(run_agent)\n+\n+    def test_ollama_cloud_agent_uses_chat_completions(self, monkeypatch):\n+        \"\"\"Ollama Cloud falls through to chat_completions — no special elif needed.\"\"\"\n+        monkeypatch.setenv(\"OLLAMA_API_KEY\", \"test-key\")\n+        with patch(\"run_agent.OpenAI\") as mock_openai:\n+            mock_openai.return_value = MagicMock()\n+            from run_agent import AIAgent\n+            agent = AIAgent(\n+                model=\"qwen3.5:397b\",\n+                provider=\"ollama-cloud\",\n+                api_key=\"[redacted:secret-assignment]\",\n+                base_url=\"https://ollama.com/v1\",\n+            )\n+            assert agent.api_mode == \"chat_completions\"\n+            assert agent.provider == \"ollama-cloud\"\n+\n+\n+# ── providers.py New System ──\n+\n+class TestOllamaCloudProvidersNew:\n+    def test_overlay_exists(self):\n+        from hermes_cli.providers import HERMES_OVERLAYS\n+        assert \"ollama-cloud\" in HERMES_OVERLAYS\n+        overlay = HERMES_OVERLAYS[\"ollama-cloud\"]\n+        assert overlay.transport == \"openai_chat\"\n+        assert overlay.base_url_env_var == \"OLLAMA_BASE_URL\"\n+\n+    def test_alias_resolves(self):\n+        from hermes_cli.providers import normalize_provider as np\n+        assert np(\"ollama\") == \"custom\"  # bare \"ollama\" = local\n+        assert np(\"ollama-cloud\") == \"ollama-cloud\"\n+\n+    def test_label_override(self):\n+        from hermes_cli.providers import _LABEL_OVERRIDES\n+        assert _LABEL_OVERRIDES.get(\"ollama-cloud\") == \"Ollama Cloud\"\n+\n+    def test_get_label(self):\n+        from hermes_cli.providers import get_label\n+        assert get_label(\"ollama-cloud\") == \"Ollama Cloud\"\n+\n+    def test_get_provider(self):\n+        from hermes_cli.providers import get_provider\n+        pdef = get_provider(\"ollama-cloud\")\n+        assert pdef is not None\n+        assert pdef.id == \"ollama-cloud\"\n+        assert pdef.transport == \"openai_chat\"\n+\n+\n+# ── Auxiliary Model ──\n+\n+class TestOllamaCloudAuxiliary:\n+    def test_aux_model_defined(self):\n+        from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS\n+        assert \"ollama-cloud\" in _API_KEY_PROVIDER_AUX_MODELS\n+        assert _API_KEY_PROVIDER_AUX_MODELS[\"ollama-cloud\"] == \"nemotron-3-nano:30b\""]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -928,6 +928,7 @@ def test_max_tokens_injected(self, agent):\n         kwargs = agent._build_api_kwargs(messages)\n         assert kwargs[\"max_tokens\"] == 4096\n \n+\n     def test_qwen_portal_formats_messages_and_metadata(self, agent):\n         agent.base_url = \"https://portal.qwen.ai/v1\"\n         agent._base_url_lower = agent.base_url.lower()","@@ -984,6 +985,46 @@ def test_qwen_portal_default_max_tokens(self, agent):\n         kwargs = agent._build_api_kwargs(messages)\n         assert kwargs[\"max_tokens\"] == 65536\n \n+    def test_ollama_think_false_on_effort_none(self, agent):\n+        \"\"\"Custom (Ollama) provider with effort=none should inject think=false.\"\"\"\n+        agent.provider = \"custom\"\n+        agent.base_url = \"http://localhost:11434/v1\"\n+        agent._base_url_lower = agent.base_url.lower()\n+        agent.reasoning_config = {\"effort\": \"none\"}\n+        messages = [{\"role\": \"user\", \"content\": \"hi\"}]\n+        kwargs = agent._build_api_kwargs(messages)\n+        assert kwargs.get(\"extra_body\", {}).get(\"think\") is False\n+\n+    def test_ollama_think_false_on_enabled_false(self, agent):\n+        \"\"\"Custom (Ollama) provider with enabled=false should inject think=false.\"\"\"\n+        agent.provider = \"custom\"\n+        agent.base_url = \"http://localhost:11434/v1\"\n+        agent._base_url_lower = agent.base_url.lower()\n+        agent.reasoning_config = {\"enabled\": False}\n+        messages = [{\"role\": \"user\", \"content\": \"hi\"}]\n+        kwargs = agent._build_api_kwargs(messages)\n+        assert kwargs.get(\"extra_body\", {}).get(\"think\") is False\n+\n+    def test_ollama_no_think_param_when_reasoning_enabled(self, agent):\n+        \"\"\"Custom provider with reasoning enabled should NOT inject think=false.\"\"\"\n+        agent.provider = \"custom\"\n+        agent.base_url = \"http://localhost:11434/v1\"\n+        agent._base_url_lower = agent.base_url.lower()\n+        agent.reasoning_config = {\"enabled\": True, \"effort\": \"medium\"}\n+        messages = [{\"role\": \"user\", \"content\": \"hi\"}]\n+        kwargs = agent._build_api_kwargs(messages)\n+        assert kwargs.get(\"extra_body\", {}).get(\"think\") is None\n+\n+    def test_non_custom_provider_unaffected(self, agent):\n+        \"\"\"OpenRouter provider with effort=none should NOT inject think=false.\"\"\"\n+        agent.provider = \"openrouter\"\n+        agent.model = \"qwen/qwen3.5-plus-02-15\"\n+        agent.reasoning_config = {\"effort\": \"none\"}\n+        messages = [{\"role\": \"user\", \"content\": \"hi\"}]\n+        kwargs = agent._build_api_kwargs(messages)\n+        assert kwargs.get(\"extra_body\", {}).get(\"think\") is None\n+\n+\n \n class TestBuildAssistantMessage:\n     def test_basic_message(self, agent):","@@ -2202,6 +2243,114 @@ def test_length_finish_reason_requests_continuation(self, agent):\n         assert second_call_messages[-1][\"role\"] == \"user\"\n         assert \"truncated by the output length limit\" in second_call_messages[-1][\"content\"]\n \n+    def test_ollama_glm_stop_after_tools_without_terminal_boundary_requests_continuation(self, agent):\n+        \"\"\"Ollama-hosted GLM responses can misreport truncated output as stop.\"\"\"\n+        self._setup_agent(agent)\n+        agent.base_url = \"http://localhost:11434/v1\"\n+        agent._base_url_lower = agent.base_url.lower()\n+        agent.model = \"glm-5.1:cloud\"\n+\n+        tool_turn = _mock_response(\n+            content=\"\",\n+            finish_reason=\"tool_calls\",\n+            tool_calls=[_mock_tool_call(name=\"web_search\", arguments=\"{}\", call_id=\"c1\")],\n+        )\n+        misreported_stop = _mock_response(\n+            content=\"Based on the search results, the best next\",\n+            finish_reason=\"stop\",\n+        )\n+        continued = _mock_response(\n+            content=\" step is to update the config.\",\n+            finish_reason=\"stop\",\n+        )\n+        agent.client.chat.completions.create.side_effect = [\n+            tool_turn,\n+            misreported_stop,\n+            continued,\n+        ]\n+\n+        with (\n+            patch(\"run_agent.handle_function_call\", return_value=\"search result\"),\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+        ):\n+            result = agent.run_conversation(\"hello\")\n+\n+        assert result[\"completed\"] is True\n+        assert result[\"api_calls\"] == 3\n+        assert (\n+            result[\"final_response\"]\n+            == \"Based on the search results, the best next step is to update the config.\"\n+        )\n+\n+        third_call_messages = agent.client.chat.completions.create.call_args_list[2].kwargs[\"messages\"]\n+        assert third_call_messages[-1][\"role\"] == \"user\"\n+        assert \"truncated by the output length limit\" in third_call_messages[-1][\"content\"]\n+\n+    def test_ollama_glm_stop_with_terminal_boundary_does_not_continue(self, agent):\n+        \"\"\"Complete Ollama/GLM responses should not be reclassified as truncated.\"\"\"\n+        self._setup_agent(agent)\n+        agent.base_url = \"http://localhost:11434/v1\"\n+        agent._base_url_lower = agent.base_url.lower()\n+        agent.model = \"glm-5.1:cloud\"\n+\n+        tool_turn = _mock_response(\n+            content=\"\",\n+            finish_reason=\"tool_calls\",\n+            tool_calls=[_mock_tool_call(name=\"web_search\", arguments=\"{}\", call_id=\"c1\")],\n+        )\n+        complete_stop = _mock_response(\n+            content=\"Based on the search results, the best next step is to update the config.\",\n+            finish_reason=\"stop\",\n+        )\n+        agent.client.chat.completions.create.side_effect = [tool_turn, complete_stop]\n+\n+        with (\n+            patch(\"run_agent.handle_function_call\", return_value=\"search result\"),\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+        ):\n+            result = agent.run_conversation(\"hello\")\n+\n+        assert result[\"completed\"] is True\n+        assert result[\"api_calls\"] == 2\n+        assert (\n+            result[\"final_response\"]\n+            == \"Based on the search results, the best next step is to update the config.\"\n+        )\n+\n+    def test_non_ollama_stop_without_terminal_boundary_does_not_continue(self, agent):\n+        \"\"\"The stop->length workaround should stay scoped to Ollama/GLM backends.\"\"\"\n+        self._setup_agent(agent)\n+        agent.base_url = \"https://api.openai.com/v1\"\n+        agent._base_url_lower = agent.base_url.lower()\n+        agent.model = \"gpt-4o-mini\"\n+\n+        tool_turn = _mock_response(\n+            content=\"\",\n+            finish_reason=\"tool_calls\",\n+            tool_calls=[_mock_tool_call(name=\"web_search\", arguments=\"{}\", call_id=\"c1\")],\n+        )\n+        normal_stop = _mock_response(\n+            content=\"Based on the search results, the best next\",\n+            finish_reason=\"stop\",\n+        )\n+        agent.client.chat.completions.create.side_effect = [tool_turn, normal_stop]\n+\n+        with (\n+            patch(\"run_agent.handle_function_call\", return_value=\"search result\"),\n+            patch.object(agent, \"_persist_session\"),\n+            patch.object(agent, \"_save_trajectory\"),\n+            patch.object(agent, \"_cleanup_task_resources\"),\n+        ):\n+            result = agent.run_conversation(\"hello\")\n+\n+        assert result[\"completed\"] is True\n+        assert result[\"api_calls\"] == 2\n+        assert result[\"final_response\"] == \"Based on the search results, the best next\"\n+\n     def test_length_thinking_exhausted_skips_continuation(self, agent):\n         \"\"\"When finish_reason='length' but content is only thinking, skip retries.\"\"\"\n         self._setup_agent(agent)\n"]}]},{"kind":"pr","number":25866,"title":"fix(agent): propagate reasoning_effort to Ollama + curator fork","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":29820,"title":"fix(ollama): emit top-level reasoning_effort=none on /v1/chat/complet…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#25866","number":25866},{"kind":"pr","value":"#25758","number":25758},{"kind":"pr","value":"#6152","number":6152}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":42201,"title":"Disable qwen3 thinking on local Ollama so tool-calling works (fixes #6152)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"67df958dbe06bb10ca16b8686c76baf0de3bac03","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":46489,"title":"fix(transports): promote Ollama reasoning field to content when content is empty","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":105996,"title":"fix(transport): enforce reasoning output floor so tight max_tokens can't starve the answer","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"fcd7df372c","number":0},{"kind":"pr","value":"#9452","number":9452}],"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}