Prompt:
Rescues `sovereign` branch and `fix/vision-api-key-fallback` branch.

## What this includes

**Commit 1: `feat: fallback chain with recovery — Groq, Kimi, local Ollama`**

Cascade DOWN through providers on rate limit/failure:
  Anthropic (primary) → Groq → Kimi → Local Ollama

Periodically probes back UP toward primary (every 5 successful calls). Full restore when primary recovers.

Changes:
- `run_agent.py`: chain cascade + recovery engine
- `hermes_cli/auth.py`: Groq added to PROVIDER_REGISTRY (conflict resolved: ollama was already added by main)
- `agent/auxiliary_client.py`: Groq default model (llama-3.3-70b-versatile)
- `cli.py` + `gateway/run.py`: load chain (list) or legacy dict
- `hermes_cli/config.py`: handle list format in config writer
- `tests/test_fallback_model.py`: 37/37 passing (9 new chain tests + Groq credential test)

**Commit 2: `fix: include API-key providers in vision auxiliary chain`**

The vision auxiliary client (`get_vision_auxiliary_client`) was missing `_resolve_api_key_provider` from its auto-detection chain. Users with only a direct API-key provider (Anthropic, Groq, Kimi) got `(None, None)` from the vision client while the text client worked fine.

## Notes

Conflict in `hermes_cli/auth.py` was resolved by keeping both new providers (ollama from main + groq from sovereign). Both are independent entries.

Original commits by @rockachopa (Alexander Whitestone) on sovereign branch, 2026-03-14.

Chosen response:
diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py
index 13efa8db..4cfa1736 100644
--- a/agent/auxiliary_client.py
+++ b/agent/auxiliary_client.py
@@ -53,6 +53,7 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
     "minimax": "MiniMax-M2.5-highspeed",
     "minimax-cn": "MiniMax-M2.5-highspeed",
     "anthropic": "claude-haiku-4-5-20251001",
+    "groq": "llama-3.3-70b-versatile",
 }

 # OpenRouter app attribution headers
@@ -789,7 +790,7 @@ def get_vision_auxiliary_client() -> Tuple[Optional[OpenAI], Optional[str]]:
     # LLaVA, Pixtral, etc.) support vision — skipping them entirely
     # caused silent failures for local-only users.
     for try_fn in (_try_openrouter, _try_nous, _try_codex,
-                   _try_custom_endpoint):
+                   _try_custom_endpoint, _resolve_api_key_provider):
         client, model = try_fn()
         if client is not None:
             return client, model
diff --git a/cli.py b/cli.py
index 93771174..11a6e3a1 100755
--- a/cli.py
+++ b/cli.py
@@ -1258,9 +1258,18 @@ class HermesCLI:
         self._provider_require_params = pr.get("require_parameters", False)
         self._provider_data_collection = pr.get("data_collection")

-        # Fallback model config — tried when primary provider fails after retries
-        fb = CLI_CONFIG.get("fallback_model") or {}
-        self._fallback_model = fb if fb.get("provider") and fb.get("model") else None
+        # Fallback model chain — tried in order when primary provider fails.
+        # Supports both legacy single-dict and new list-of-dicts format.
+        fb_raw = CLI_CONFIG.get("fallback_model")
+        if isinstance(fb_raw, list):
+            self._fallback_model = [
+                entry for entry in fb_raw
+                if isinstance(entry, dict) and entry.get("provider") and entry.get("model")
+            ] or None
+        elif isinstance(fb_raw, dict) and fb_raw.get("provider") and fb_raw.get("model"):
+            self._fallback_model = fb_raw
+        else:
+            self._fallback_model = None

         # AutoLoRA live compression config — post-session hook (optional, disabled by default)
         _autolora_cfg = CLI_CONFIG.get("autolora", {})
diff --git a/gateway/run.py b/gateway/run.py
index 221f8f91..b1b45962 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -563,11 +563,11 @@ class GatewayRunner:
         return {}

     @staticmethod
-    def _load_fallback_model() -> dict | None:
+    def _load_fallback_model():
         """Load fallback model config from config.yaml.

-        Returns a dict with 'provider' and 'model' keys, or None if
-        not configured / both fields empty.
+        Returns a list of dicts (chain), a single dict (legacy), or None.
+        Supports both old single-entry and new list-of-entries formats.
         """
         try:
             import yaml as _y
@@ -575,8 +575,15 @@ class GatewayRunner:
             if cfg_path.exists():
                 with open(cfg_path, encoding="utf-8") as _f:
                     cfg = _y.safe_load(_f) or {}
-                fb = cfg.get("fallback_model", {}) or {}
-                if fb.get("provider") and fb.get("model"):
+                fb = cfg.get("fallback_model")
+                if isinstance(fb, list):
+                    chain = [
+                        entry for entry in fb
+                        if isinstance(entry, dict)
+                        and entry.get("provider") and entry.get("model")
+                    ]
+                    return chain or None
+                if isinstance(fb, dict) and fb.get("provider") and fb.get("model"):
                     return fb
         except Exception:
             pass
diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py
index de28d749..11fcee10 100644
--- a/hermes_cli/auth.py
+++ b/hermes_cli/auth.py
@@ -155,6 +155,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
         api_key_env_vars=("OLLAMA_API_KEY",),
         base_url_env_var="OLLAMA_BASE_URL",
     ),
+    "groq": ProviderConfig(
+        id="groq",
+        name="Groq",
+        auth_type="api_key",
+        inference_base_url="https://api.groq.com/openai/v1",
+        api_key_env_vars=("GROQ_API_KEY",),
+        base_url_env_var="GROQ_BASE_URL",
+    ),
 }

diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 07adbe55..4b506b03 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -989,8 +989,12 @@ def save_config(config: Dict[str, Any]):
     sec = normalized.get("security", {})
     if not sec or sec.get("redact_secrets") is None:
         parts.append(_SECURITY_COMMENT)
-    fb = normalized.get("fallback_model", {})
-    if not fb or not (fb.get("provider") and fb.get("model")):
+    fb = normalized.get("fallback_model")
+    fb_configured = (
+        (isinstance(fb, list) and len(fb) > 0) or
+        (isinstance(fb, dict) and fb.get("provider") and fb.get("model"))
+    )
+    if not fb_configured:
         parts.append(_FALLBACK_COMMENT)

     atomic_yaml_write(
diff --git a/run_agent.py b/run_agent.py
index 905dcaca..036a0503 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -571,16 +571,31 @@ class AIAgent:
             except Exception as e:
                 raise RuntimeError(f"Failed to initialize OpenAI client: {e}")

-        # Provider fallback — a single backup model/provider tried when the
-        # primary is exhausted (rate-limit, overload, connection failure).
-        # Config shape: {"provider": "openrouter", "model": "anthropic/claude-sonnet-4"}
-        self._fallback_model = fallback_model if isinstance(fallback_model, dict) else None
+        # Provider fallback chain — ordered list of backup model/providers
+        # tried sequentially when the primary (or previous fallback) fails.
+        # Config shape: single dict (legacy) or list of dicts.
+        #   {"provider": "kimi-coding", "model": "kimi-k2.5"}
+        #   [{"provider": "kimi-coding", "model": "kimi-k2.5"},
+        #    {"provider": "custom", "model": "qwen3.5:latest",
+        #     "base_url": "http://localhost:11434/v1", "api_key": "ollama"}]
+        if isinstance(fallback_model, dict):
+            self._fallback_chain = [fallback_model]
+        elif isinstance(fallback_model, list):
+            self._fallback_chain = [fb for fb in fallback_model if isinstance(fb, dict)]
+        else:
+            self._fallback_chain = []
+        self._fallback_chain_index = 0
+        # Legacy compat: expose first entry as _fallback_model, bool as _fallback_activated
+        self._fallback_model = self._fallback_chain[0] if self._fallback_chain else None
         self._fallback_activated = False
-        if self._fallback_model:
-            fb_p = self._fallback_model.get("provider", "")
-            fb_m = self._fallback_model.get("model", "")
-            if fb_p and fb_m and not self.quiet_mode:
-                print(f"🔄 Fallback model: {fb_m} ({fb_p})")
+        if self._fallback_chain and not self.quiet_mode:
+            chain_desc = " → ".join(
+                f"{fb.get('model', '?')} ({fb.get('provider', '?')})"
+                for fb in self._fallback_chain
+                if fb.get("provider") and fb.get("model")
+            )
+            if chain_desc:
+                print(f"🔄 Fallback chain: {chain_desc}")

         # Get available tools with filtering
         self.tools = get_tool_definitions(
@@ -2651,42 +2666,168 @@ class AIAgent:
             raise result["error"]
         return result["response"]

-    # ── Provider fallback ──────────────────────────────────────────────────
+    # ── Provider fallback chain ──────────────────────────────────────────────
+    # Cascades down through configured providers on failure.
+    # Periodically attempts to recover back UP the chain (primary preferred).
+    #
+    # Chain index -1 = primary provider (the one configured at init).
+    # Index 0..N-1 = entries from _fallback_chain list.
+
+    # How many successful API calls before we try recovering one level up.
+    _RECOVERY_INTERVAL = 5

     def _try_activate_fallback(self) -> bool:
-        """Switch to the configured fallback model/provider.
+        """Cascade one step down the fallback chain.

-        Called when the primary model is failing after retries.  Swaps the
-        OpenAI client, model slug, and provider in-place so the retry loop
-        can continue with the new backend.  One-shot: returns False if
-        already activated or not configured.
+        Called when the current provider is failing after retries.  Walks
+        _fallback_chain_index forward and activates the next provider.
+        Returns False when the chain is exhausted (no more providers to try).

-        Uses the centralized provider router (resolve_provider_client) for
-        auth resolution and client construction — no duplicated provider→key
-        mappings.
+        Legacy compat: sets _fallback_activated = True on first activation
+        so old call-sites that check the bool still work.
         """
-        if self._fallback_activated or not self._fallback_model:
+        if not self._fallback_chain:
             return False

-        fb = self._fallback_model
+        # Walk forward through the chain until we find one that works
+        while self._fallback_chain_index < len(self._fallback_chain):
+            fb = self._fallback_chain[self._fallback_chain_index]
+            self._fallback_chain_index += 1
+
+            if self._activate_provider(fb, direction="down"):
+                return True
+
+        # Chain exhausted
+        logging.warning("Fallback chain exhausted — no more providers to try")
+        return False
+
+    def _try_recover_up(self) -> bool:
+        """Attempt to recover one level up the chain toward the primary.
+
+        Called periodically after successful responses.  Probes the provider
+        one step above the current position with a lightweight check, then
+        switches back if healthy.  Returns True if we moved up.
+        """
+        if self._fallback_chain_index <= 0:
+            # Already on primary or first fallback — try primary directly
+            if self._fallback_activated and self._primary_snapshot:
+                return self._try_restore_primary()
+            return False
+
+        # Try the provider one slot above our current position
+        target_idx = self._fallback_chain_index - 2  # -2 because index is post-increment
+        if target_idx < 0:
+            # Target is the primary
+            return self._try_restore_primary()
+
+        fb = self._fallback_chain[target_idx]
         fb_provider = (fb.get("provider") or "").strip().lower()
         fb_model = (fb.get("model") or "").strip()
         if not fb_provider or not fb_model:
             return False

-        # Use centralized router for client construction.
-        # raw_codex=True because the main agent needs direct responses.stream()
-        # access for Codex providers.
+        # Probe: can we create a client?
         try:
-            from agent.auxiliary_client import resolve_provider_client
-            fb_client, _ = resolve_provider_client(
-                fb_provider, model=fb_model, raw_codex=True)
+            client, _ = self._resolve_fallback_client(fb)
+            if client is None:
+                return False
+        except Exception:
+            return False
+
+        # Activate the higher-priority provider
+        if self._activate_provider(fb, direction="up"):
+            self._fallback_chain_index = target_idx + 1  # Point past the one we just activated
+            return True
+        return False
+
+    def _try_restore_primary(self) -> bool:
+        """Try to switch back to the original primary provider."""
+        snap = getattr(self, "_primary_snapshot", None)
+        if not snap:
+            return False
+
+        try:
+            # For anthropic primary, probe with a lightweight client build
+            if snap["api_mode"] == "anthropic_messages":
+                from agent.anthropic_adapter import build_anthropic_client
+                test_client = build_anthropic_client(snap["anthropic_api_key"])
+                if test_client is None:
+                    return False
+            elif snap.get("client_kwargs"):
+                from openai import OpenAI as _OpenAI
+                test_client = _OpenAI(**snap["client_kwargs"])
+            else:
+                return False
+
+            # Restore primary state
+            old_model = self.model
+            self.model = snap["model"]
+            self.provider = snap["provider"]
+            self.base_url = snap["base_url"]
+            self.api_mode = snap["api_mode"]
+            self._use_prompt_caching = snap["use_prompt_caching"]
+
+            if snap["api_mode"] == "anthropic_messages":
+                from agent.anthropic_adapter import build_anthropic_client
+                self._anthropic_api_key = snap["anthropic_api_key"]
+                self._anthropic_client = build_anthropic_client(snap["anthropic_api_key"])
+                self.client = None
+                self._client_kwargs = {}
+            else:
+                self.client = test_client
+                self._client_kwargs = snap.get("client_kwargs", {})
+
+            self._fallback_activated = False
+            self._fallback_chain_index = 0
+            self._recovery_call_count = 0
+
+            print(
+                f"{self.log_prefix}✅ Recovered to primary: "
+                f"{snap['model']} via {snap['provider']}"
+            )
+            logging.info(
+                "Recovery to primary: %s → %s (%s)",
+                old_model, snap["model"], snap["provider"],
+            )
+            return True
+        except Exception as e:
+            logging.debug("Primary recovery probe failed: %s", e)
+            return False
+
+    def _activate_provider(self, fb: dict, direction: str = "down") -> bool:
+        """Activate a specific fallback provider entry.
+
+        Args:
+            fb: Dict with 'provider', 'model', and optionally 'base_url'/'api_key'.
+            direction: 'down' (cascade on failure) or 'up' (recovery).
+
+        Returns True on success.
+        """
+        fb_provider = (fb.get("provider") or "").strip().lower()
+        fb_model = (fb.get("model") or "").strip()
+        if not fb_provider or not fb_model:
+            return False
+
+        try:
+            fb_client, _ = self._resolve_fallback_client(fb)
             if fb_client is None:
                 logging.warning(
                     "Fallback to %s failed: provider not configured",
                     fb_provider)
                 return False

+            # Snapshot the current (primary) state on first fallback activation
+            if not self._fallback_activated and direction == "down":
+                self._primary_snapshot = {
+                    "model": self.model,
+                    "provider": self.provider,
+                    "base_url": self.base_url,
+                    "api_mode": self.api_mode,
+                    "use_prompt_caching": self._use_prompt_caching,
+                    "client_kwargs": getattr(self, "_client_kwargs", {}),
+                    "anthropic_api_key": getattr(self, "_anthropic_api_key", ""),
+                }
+
             # Determine api_mode from provider
             fb_api_mode = "chat_completions"
             if fb_provider == "openai-codex":
@@ -2701,9 +2842,9 @@ class AIAgent:
             self.base_url = fb_base_url
             self.api_mode = fb_api_mode
             self._fallback_activated = True
+            self._recovery_call_count = 0

             if fb_api_mode == "anthropic_messages":
-                # Build native Anthropic client instead of using OpenAI client
                 from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
                 effective_key = fb_client.api_key or resolve_anthropic_token() or ""
                 self._anthropic_api_key = effective_key
@@ -2711,7 +2852,6 @@ class AIAgent:
                 self.client = None
                 self._client_kwargs = {}
             else:
-                # Swap OpenAI client and config in-place
                 self.client = fb_client
                 self._client_kwargs = {
                     "api_key": fb_client.api_key,
@@ -2725,20 +2865,57 @@ class AIAgent:
                 or is_native_anthropic
             )

+            arrow = "⬇" if direction == "down" else "⬆"
+            label = "Falling back" if direction == "down" else "Recovering"
             print(
-                f"{self.log_prefix}🔄 Primary model failed — switching to fallback: "
-                f"{fb_model} via {fb_provider}"
+                f"{self.log_prefix}{arrow} {label}: "
+                f"{old_model} → {fb_model} via {fb_provider}"
             )
             logging.info(
-                "Fallback activated: %s → %s (%s)",
-                old_model, fb_model, fb_provider,
+                "Fallback chain %s: %s → %s (%s)",
+                direction, old_model, fb_model, fb_provider,
             )
             return True
         except Exception as e:
-            logging.error("Failed to activate fallback model: %s", e)
+            logging.error("Failed to activate %s fallback (%s): %s",
+                          direction, fb.get("provider"), e)
             return False

-    # ── End provider fallback ──────────────────────────────────────────────
+    def _resolve_fallback_client(self, fb: dict):
+        """Build a client for a fallback entry.
+
+        Supports both registry-based providers (via resolve_provider_client)
+        and inline custom entries with explicit base_url + api_key.
+        """
+        fb_provider = (fb.get("provider") or "").strip().lower()
+        fb_model = (fb.get("model") or "").strip()
+
+        # Inline custom endpoint (base_url + api_key in the config dict itself)
+        if fb.get("base_url"):
+            from openai import OpenAI as _OpenAI
+            api_key = fb.get("api_key") or "no-key"
+            client = _OpenAI(api_key=api_key, base_url=fb["base_url"])
+            return client, fb_model
+
+        # Use centralized provider router
+        from agent.auxiliary_client import resolve_provider_client
+        return resolve_provider_client(
+            fb_provider, model=fb_model, raw_codex=True)
+
+    def _maybe_try_recovery(self):
+        """Called after each successful API response to track recovery timing.
+
+        Every _RECOVERY_INTERVAL successful calls while on a fallback,
+        attempt to move one level back up the chain.
+        """
+        if not self._fallback_activated:
+            return
+        self._recovery_call_count = getattr(self, "_recovery_call_count", 0) + 1
+        if self._recovery_call_count >= self._RECOVERY_INTERVAL:
+            self._recovery_call_count = 0
+            self._try_recover_up()
+
+    # ── End provider fallback chain ────────────────────────────────────────

     def _build_api_kwargs(self, api_messages: list) -> dict:
         """Build the keyword arguments dict for the active API mode."""
@@ -4164,6 +4341,8 @@ class AIAgent:
                 break

             api_call_count += 1
+            # After a successful API call, try to recover up the fallback chain
+            self._maybe_try_recovery()
             if not self.iteration_budget.consume():
                 if not self.quiet_mode:
                     print(f"\n⚠  Session iteration budget exhausted ({self.iteration_budget.max_total} total across agent + subagents)")
diff --git a/tests/test_fallback_model.py b/tests/test_fallback_model.py
index 9e34bf74..31d780ec 100644
--- a/tests/test_fallback_model.py
+++ b/tests/test_fallback_model.py
@@ -175,26 +175,21 @@ class TestTryActivateFallback:
             assert agent._fallback_activated is False

     def test_custom_base_url(self):
-        """Custom base_url in config should override the provider default."""
+        """Custom base_url in config should build a real client from inline config."""
         agent = _make_agent(
             fallback_model={
                 "provider": "custom",
                 "model": "my-model",
                 "base_url": "http://localhost:8080/v1",
-                "api_key_env": "MY_CUSTOM_KEY",
+                "api_key": "test-key",
             },
         )
-        mock_client = _mock_resolve(
-            api_key="custom-secret",
-            base_url="http://localhost:8080/v1",
-        )
-        with patch(
-            "agent.auxiliary_client.resolve_provider_client",
-            return_value=(mock_client, "my-model"),
-        ):
-            assert agent._try_activate_fallback() is True
-            assert agent.client is mock_client
-            assert agent.model == "my-model"
+        # Inline base_url entries bypass resolve_provider_client entirely
+        # and build a client directly from the config dict.
+        assert agent._try_activate_fallback() is True
+        assert agent.model == "my-model"
+        assert agent.client is not None
+        assert "localhost:8080" in str(agent.client.base_url)

     def test_prompt_caching_enabled_for_claude_on_openrouter(self):
         agent = _make_agent(
@@ -358,6 +353,7 @@ class TestProviderCredentials:
         ("kimi-coding", "KIMI_API_KEY", "moonshot.ai"),
         ("minimax", "MINIMAX_API_KEY", "minimax.io"),
         ("minimax-cn", "MINIMAX_CN_API_KEY", "minimaxi.com"),
+        ("groq", "GROQ_API_KEY", "groq.com"),
     ])
     def test_provider_resolves(self, provider, env_var, base_url_fragment):
         agent = _make_agent(
@@ -375,3 +371,147 @@ class TestProviderCredentials:
             assert agent.client is mock_client
             assert agent.model == "test-model"
             assert agent.provider == provider
+
+
+# =============================================================================
+# Fallback chain (list of providers)
+# =============================================================================
+
+class TestFallbackChain:
+    """Verify cascade-down and recover-up behavior with a chain of providers."""
+
+    def test_chain_init_from_list(self):
+        chain = [
+            {"provider": "groq", "model": "llama-3.3-70b-versatile"},
+            {"provider": "kimi-coding", "model": "kimi-k2.5"},
+            {"provider": "custom", "model": "qwen3.5:latest",
+             "base_url": "http://localhost:11434/v1", "api_key": "ollama"},
+        ]
+        agent = _make_agent(fallback_model=chain)
+        assert agent._fallback_chain == chain
+        assert agent._fallback_chain_index == 0
+        assert agent._fallback_model == chain[0]  # legacy compat
+
+    def test_chain_init_from_single_dict(self):
+        fb = {"provider": "groq", "model": "llama-3.3-70b-versatile"}
+        agent = _make_agent(fallback_model=fb)
+        assert agent._fallback_chain == [fb]
+        assert agent._fallback_model == fb
+
+    def test_chain_init_empty_list(self):
+        agent = _make_agent(fallback_model=[])
+        assert agent._fallback_chain == []
+        assert agent._fallback_model is None
+
+    def test_chain_cascades_through_multiple_providers(self):
+        chain = [
+            {"provider": "groq", "model": "llama-3.3-70b-versatile"},
+            {"provider": "kimi-coding", "model": "kimi-k2.5"},
+        ]
+        agent = _make_agent(fallback_model=chain)
+
+        # First fallback: groq fails, kimi succeeds
+        mock_groq = MagicMock()
+        mock_groq.base_url = "https://api.groq.com/openai/v1"
+        mock_groq.api_key = "gsk-test"
+
+        with patch(
+            "agent.auxiliary_client.resolve_provider_client",
+            return_value=(None, None),  # groq unavailable
+        ):
+            assert agent._try_activate_fallback() is False  # groq fails
+
+        # Reset — now test that kimi was also tried and index advanced
+        assert agent._fallback_chain_index == 2  # past both entries
+
+    def test_chain_cascades_down_then_succeeds(self):
+        chain = [
+            {"provider": "groq", "model": "llama-3.3-70b-versatile"},
+            {"provider": "kimi-coding", "model": "kimi-k2.5"},
+        ]
+        agent = _make_agent(fallback_model=chain)
+
+        mock_kimi = MagicMock()
+        mock_kimi.base_url = "https://api.moonshot.ai/v1"
+        mock_kimi.api_key = "sk-test"
+
+        call_count = [0]
+        def side_effect(provider, model=None, raw_codex=False):
+            call_count[0] += 1
+            if call_count[0] == 1:  # groq fails
+                return (None, None)
+            return (mock_kimi, "kimi-k2.5")  # kimi succeeds
+
+        with patch(
+            "agent.auxiliary_client.resolve_provider_client",
+            side_effect=side_effect,
+        ):
+            result = agent._try_activate_fallback()
+            assert result is True
+            assert agent.model == "kimi-k2.5"
+            assert agent.provider == "kimi-coding"
+
+    def test_inline_custom_endpoint_bypasses_resolver(self):
+        chain = [
+            {"provider": "custom", "model": "qwen3.5:latest",
+             "base_url": "http://localhost:11434/v1", "api_key": "ollama"},
+        ]
+        agent = _make_agent(fallback_model=chain)
+
+        # Should NOT call resolve_provider_client at all
+        with patch(
+            "agent.auxiliary_client.resolve_provider_client",
+            side_effect=AssertionError("should not be called"),
+        ):
+            result = agent._try_activate_fallback()
+            assert result is True
+            assert agent.model == "qwen3.5:latest"
+            assert "localhost:11434" in str(agent.client.base_url)
+
+    def test_primary_snapshot_saved_on_first_fallback(self):
+        chain = [{"provider": "groq", "model": "llama-3.3-70b-versatile"}]
+        agent = _make_agent(fallback_model=chain)
+        original_model = agent.model
+        original_provider = agent.provider
+
+        mock_client = MagicMock()
+        mock_client.base_url = "https://api.groq.com/openai/v1"
+        mock_client.api_key = "gsk-test"
+
+        with patch(
+            "agent.auxiliary_client.resolve_provider_client",
+            return_value=(mock_client, "llama-3.3-70b-versatile"),
+        ):
+            agent._try_activate_fallback()
+
+        assert hasattr(agent, "_primary_snapshot")
+        assert agent._primary_snapshot["model"] == original_model
+        assert agent._primary_snapshot["provider"] == original_provider
+
+    def test_recovery_counter_increments(self):
+        chain = [{"provider": "groq", "model": "llama-3.3-70b-versatile"}]
+        agent = _make_agent(fallback_model=chain)
+        agent._fallback_activated = True
+        agent._recovery_call_count = 0
+
+        # Call _maybe_try_recovery a few times — should increment counter
+        agent._fallback_chain_index = 1
+        agent._primary_snapshot = None  # no snapshot, recovery can't work
+
+        for i in range(4):
+            agent._maybe_try_recovery()
+            assert agent._recovery_call_count == i + 1
+
+    def test_chain_exhausted_returns_false(self):
+        chain = [{"provider": "groq", "model": "llama-3.3-70b-versatile"}]
+        agent = _make_agent(fallback_model=chain)
+
+        with patch(
+            "agent.auxiliary_client.resolve_provider_client",
+            return_value=(None, None),
+        ):
+            result = agent._try_activate_fallback()
+            assert result is False
+            # Second call should also return False (chain exhausted)
+            result2 = agent._try_activate_fallback()
+            assert result2 is False
diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py
index 15a0d5fb..f05eebc4 100644
--- a/tests/test_run_agent.py
+++ b/tests/test_run_agent.py
@@ -1992,6 +1992,8 @@ class TestFallbackAnthropicProvider:
     def test_fallback_to_anthropic_sets_api_mode(self, agent):
         agent._fallback_activated = False
         agent._fallback_model = {"provider": "anthropic", "model": "claude-sonnet-4-20250514"}
+        agent._fallback_chain = [agent._fallback_model]
+        agent._fallback_chain_index = 0

         mock_client = MagicMock()
         mock_client.base_url = "https://api.anthropic.com/v1"
@@ -2013,6 +2015,8 @@ class TestFallbackAnthropicProvider:
     def test_fallback_to_anthropic_enables_prompt_caching(self, agent):
         agent._fallback_activated = False
         agent._fallback_model = {"provider": "anthropic", "model": "claude-sonnet-4-20250514"}
+        agent._fallback_chain = [agent._fallback_model]
+        agent._fallback_chain_index = 0

         mock_client = MagicMock()
         mock_client.base_url = "https://api.anthropic.com/v1"
@@ -2030,6 +2034,8 @@ class TestFallbackAnthropicProvider:
     def test_fallback_to_openrouter_uses_openai_client(self, agent):
         agent._fallback_activated = False
         agent._fallback_model = {"provider": "openrouter", "model": "anthropic/claude-sonnet-4"}
+        agent._fallback_chain = [agent._fallback_model]
+        agent._fallback_chain_index = 0

         mock_client = MagicMock()
         mock_client.base_url = "https://openrouter.ai/api/v1"
diff --git a/tests/test_vision_auxiliary_chain.py b/tests/test_vision_auxiliary_chain.py
new file mode 100644
index 00000000..93138a5f
--- /dev/null
+++ b/tests/test_vision_auxiliary_chain.py
@@ -0,0 +1,74 @@
+"""Tests for get_vision_auxiliary_client resolution chain.
+
+Regression: the vision auxiliary client was missing _resolve_api_key_provider
+from its auto-detection chain. Users with only an Anthropic (or other direct
+API-key provider) key got (None, None) from get_vision_auxiliary_client while
+get_auxiliary_client worked fine for text tasks.
+"""
+
+import os
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def _clean_env(monkeypatch):
+    """Strip all provider keys so each test controls its own env."""
+    for var in (
+        "OPENROUTER_API_KEY",
+        "OPENAI_API_KEY",
+        "OPENAI_BASE_URL",
+        "ANTHROPIC_API_KEY",
+        "NOUS_API_KEY",
+    ):
+        monkeypatch.delenv(var, raising=False)
+
+
+def test_vision_falls_back_to_api_key_provider(monkeypatch):
+    """When only an API-key provider (e.g. Anthropic) is configured,
+    get_vision_auxiliary_client should still return a client."""
+    monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test-key")
+
+    with patch("agent.auxiliary_client.OpenAI") as mock_openai:
+        mock_client = MagicMock()
+        mock_openai.return_value = mock_client
+
+        from agent.auxiliary_client import get_vision_auxiliary_client
+
+        client, model = get_vision_auxiliary_client()
+
+    assert client is not None, (
+        "Vision client should resolve via _resolve_api_key_provider "
+        "when only an API-key provider is available"
+    )
+    assert model is not None
+
+
+def test_vision_prefers_openrouter_over_api_key(monkeypatch):
+    """OpenRouter should be tried before falling back to API-key providers."""
+    monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test-key")
+    monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test-key")
+
+    with patch("agent.auxiliary_client.OpenAI") as mock_openai:
+        mock_client = MagicMock()
+        mock_openai.return_value = mock_client
+
+        from agent.auxiliary_client import get_vision_auxiliary_client
+
+        client, model = get_vision_auxiliary_client()
+
+    assert client is not None
+    # Should have picked OpenRouter (first in chain), not Anthropic
+    call_kwargs = mock_openai.call_args
+    assert "openrouter" in str(call_kwargs).lower()
+
+
+def test_vision_returns_none_when_no_providers(monkeypatch):
+    """With no API keys at all, vision should return (None, None) gracefully."""
+    from agent.auxiliary_client import get_vision_auxiliary_client
+
+    client, model = get_vision_auxiliary_client()
+
+    assert client is None
+    assert model is None


Prompt:
Fixes #40

Adds `docs/timmy-dashboard-duplication-audit.md` with component-by-component Kill/Keep/Redirect recommendations.

## What was audited

- `src/timmy/tools/` — Full Agno-based tool system
- `src/timmy/memory_system.py` — SQLite + embedding memory
- `src/timmy/thinking.py` — Background inner monologue engine

## Key findings

**Kill (redirect to Hermes):** Core tool registrations for web_fetch, file I/O, and shell execution. Hermes already provides these via `tools/web_tools.py`, `tools/file_tools.py`, and `tools/terminal_tool.py`.

**Keep:** Swarm delegation tools, gematria tool, per-agent toolkit registry — all dashboard-specific with no Hermes equivalent.

**Keep:** `memory_system.py` — materially richer than Hermes (semantic search, embeddings, SQLite vault). Hermes `memory_tool.py` is file-only with no semantic search. Recommend aligning MEMORY.md path conventions.

**Keep:** `thinking.py` — Background autonomous thought generation has no Hermes equivalent. Legitimately dashboard-specific.

Chosen response:
diff --git a/docs/timmy-dashboard-duplication-audit.md b/docs/timmy-dashboard-duplication-audit.md
new file mode 100644
index 00000000..b397ae26
--- /dev/null
+++ b/docs/timmy-dashboard-duplication-audit.md
@@ -0,0 +1,122 @@
+# Audit: Timmy-time-dashboard vs Hermes Harness Duplication
+
+**Refs:** rockachopa/Timmy-time-dashboard#1215#issuecomment-9115, hermes-agent#40
+**Date:** 2026-03-23
+**Purpose:** Identify dashboard components that duplicate Hermes harness functionality and recommend Kill / Keep / Redirect for each.
+
+---
+
+## Background
+
+Comment #9115 on Timmy-time-dashboard#1215 is explicit:
+
+> "Kill anything that we get for free already from the hermes harness. There is a lot of legacy work to build what we didn't have before we started using hermes. We should stop doing any work that is already done in hermes harness."
+
+The dashboard's `src/timmy/` directory contains three module families flagged as likely duplicates of Hermes:
+
+- `tools.py` (now `tools/` package after PR #1221)
+- `memory_system.py`
+- `thinking.py`
+
+---
+
+## Component Analysis
+
+### 1. `src/timmy/tools/` — Tool System
+
+**What it does:**
+Provides a full tool-dispatch layer using the `agno` framework. Includes file I/O, shell execution, Python execution, web fetch, memory search/write/forget, delegation, introspection, calculator, and per-agent toolkit factories.
+
+**What Hermes provides:**
+`tools/registry.py` — central registry with the same capabilities: file operations (`tools/file_tools.py`), shell/terminal (`tools/terminal_tool.py`), web search/fetch (`tools/web_tools.py`), memory (`tools/memory_tool.py`), delegation (`tools/delegate_tool.py`), and a full tool catalog.
+
+**Verdict: REDIRECT (partial Kill)**
+
+| Sub-component | Verdict | Reason |
+|---|---|---|
+| `calculator` | **Kill** | Hermes provides no calculator but this is trivial — move to a shared util or use Python directly |
+| `web_fetch` | **Kill** | Hermes `tools/web_tools.py` (Firecrawl-backed) covers this with LLM summarization |
+| `shell` / `python` / file ops | **Kill** | Hermes `tools/terminal_tool.py` + `tools/file_tools.py` cover these |
+| `_register_memory_tools` | **Kill** | Hermes `tools/memory_tool.py` covers memory search/write/forget |
+| `_register_delegation_tools` | **Keep** | Dashboard's swarm delegation (`delegate_to_kimi`, `list_swarm_agents`) is dashboard-specific |
+| `_register_gematria_tool` | **Keep** | Dashboard-specific feature, no Hermes equivalent |
+| `AGENT_TOOLKITS` registry | **Keep** | The per-agent toolkit assignment (echo/mace/helm/seer/forge/quill) is dashboard-specific |
+| `create_full_toolkit` | **Redirect** | Replace the core tool registrations with calls into Hermes tool modules; keep wrapper only for dashboard-specific extras |
+
+**Action:** Strip the core tool registrations (`_register_core_tools`, `_register_web_fetch_tool`, `_register_memory_tools`) and wire them to the Hermes equivalents. Keep the agno wrapping only for tools Hermes doesn't have.
+
+---
+
+### 2. `src/timmy/memory_system.py` — Memory System
+
+**What it does:**
+SQLite-backed unified memory with semantic search (embeddings), hot memory (`MEMORY.md`), vault (markdown files), and four tool functions: `memory_search`, `memory_write`, `memory_read`, `memory_forget`. Also includes `jot_note` and `log_decision`.
+
+**What Hermes provides:**
+`tools/memory_tool.py` — file-backed memory (MEMORY.md + USER.md) with entry delimiters, injected into the system prompt. Simple `add`, `replace`, `remove`, `read` actions. No embeddings or SQLite.
+
+**Comparison:**
+
+| Feature | Dashboard | Hermes |
+|---|---|---|
+| Persistence | SQLite DB | Flat files (MEMORY.md, USER.md) |
+| Semantic search | Yes (embeddings) | No |
+| Hot memory injection | Yes (MEMORY.md) | Yes (MEMORY.md) |
+| Vault (long-term) | Yes | No |
+| Security scanning | No | Yes (injection/exfil patterns) |
+| Simplicity | Complex | Simple |
+
+**Verdict: KEEP (with Redirect note)**
+
+The dashboard's memory system is materially richer than Hermes — it has semantic search, embeddings, and a vault tier. It is not a straight duplicate; it extends beyond what Hermes provides.
+
+However, the `MEMORY.md` hot memory pattern is shared with Hermes. The dashboard should **not** maintain a separate file-read/write path for `MEMORY.md` — it should use the same file path conventions so both systems stay compatible.
+
+**Action:** Keep `memory_system.py` as the canonical memory implementation for Timmy. Consider contributing the semantic search and vault tiers upstream to Hermes if the harness can benefit from them. Align `MEMORY.md` path conventions with Hermes.
+
+---
+
+### 3. `src/timmy/thinking.py` — Thinking Engine
+
+**What it does:**
+A background autonomous "inner monologue" engine. Generates thoughts on a timer, stores them in SQLite (`data/thoughts.db`), extracts facts, and chains thoughts together. Exposes `search_thoughts` as a tool.
+
+**What Hermes provides:**
+No equivalent. Hermes has `tools/session_search_tool.py` (searches past session transcripts) but no autonomous background thought generation.
+
+**Verdict: KEEP**
+
+This is dashboard-specific. Hermes is a task-execution harness — it does not have a concept of ongoing background introspection. `thinking.py` is the dashboard's "always-on inner thought thread" and has no Hermes duplicate.
+
+**Action:** No changes needed. This is legitimately dashboard-specific.
+
+---
+
+## Summary Table
+
+| Component | Verdict | Action |
+|---|---|---|
+| `tools/_base.py` — ToolStats, AgentTools | Kill (if agno removed) | If agno is replaced by Hermes tool dispatch, remove |
+| `tools/file_tools.py` — file I/O toolkit factories | Kill | Wire to Hermes `tools/file_tools.py` |
+| `tools/system_tools.py` — web_fetch, shell, calculator | Kill (web_fetch/shell) | Use Hermes `tools/web_tools.py` + `tools/terminal_tool.py` |
+| `tools/_registry.py` — core tool registrations | Redirect | Stub out core registrations; call Hermes tools directly |
+| `tools/_registry.py` — AGENT_TOOLKITS, delegation, gematria | Keep | Dashboard-specific |
+| `memory_system.py` | Keep | Richer than Hermes; align MEMORY.md path conventions |
+| `memory/` (embeddings, unified, vector_store) | Keep | No Hermes equivalent |
+| `thinking.py` | Keep | No Hermes equivalent |
+
+---
+
+## Priority Order
+
+1. **Kill `web_fetch` / `web_tools` wrappers** — Hermes has a battle-tested Firecrawl-backed implementation. Dashboard's `web_fetch` in `system_tools.py` is a reimplementation.
+2. **Kill core file/shell tool registrations** — Hermes provides these. Keep only the agno wrapper layer if still needed.
+3. **Audit `_register_memory_tools`** — The four memory tool functions can call through to Hermes `memory_tool.py`, or keep the richer dashboard implementation and drop the Hermes one on the dashboard side.
+4. **Align MEMORY.md paths** — Make sure both systems read/write the same file.
+
+---
+
+## Open Questions
+
+- Should the dashboard be migrated off `agno` entirely in favor of Hermes's native tool dispatch? If yes, the entire `tools/` package becomes Kill.
+- Should Hermes adopt the dashboard's semantic memory system? If yes, `memory_system.py` becomes the canonical implementation and Hermes `memory_tool.py` gets redirected.