Auxiliary model fallback routing and token cap issues

NousResearch/hermes-agent · cluster 879 · agent

Data as of

6 open items. Fix merged in #2464. Check it covers your case.

The short answer

3open issues
3open PRs
42items grouped
Known?
Yes. 42 related items grouped here, 12 issues already closed.
Fix event
A fix was merged to main in PR #2464. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #2464, #7647. That is separate from the fix event.
Still open
3 issues and 3 PRs.

Multiple related bugs and fixes around auxiliary model routing: fallback chains ignoring user configuration, max_tokens mismatches causing errors or unexpected costs, and hardcoded defaults overriding explicit provider/model selections.

Open issues3

  • Issue#50132[Bug]: _build_call_kwargs silently drops max_tokens for OpenAI-compatible endpoints, causing runaway compression summaries on local backends
  • Issue#50954Auxiliary fallback: make the hardcoded last-resort default genuinely free + add model-selection audit trail (follow-up to #36759)
  • Issue#53677Auxiliary vision fails with LiteLLM proxy + Bedrock: max_tokens omitted causes HTTP 400

Open PRs3

  • PR#50139fix(auxiliary): preserve explicit max_tokens caps (#50132)
  • PR#53681fix(auxiliary): pass max_tokens explicitly for LiteLLM/Bedrock proxies
  • PR#56957[codex] Keep OpenRouter auxiliary token caps

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #2464 fix(compression): remove hardcoded gemini-3-flash-preview as default summary model

    merged to main

    files: cli.py, hermes_cli/config.py

    Show the diff
    • cli.py
      @@ -180,7 +180,7 @@ def load_cli_config() -> Dict[str, Any]:
               "compression": {
                   "enabled": True,      # Auto-compress when approaching context limit
                   "threshold": 0.50,    # Compress at 50% of model's context limit
      -            "summary_model": "google/gemini-3-flash-preview",  # Fast/cheap model for summaries
      +            "summary_model": "",  # Model for summaries (empty = use main model)
               },
               "smart_model_routing": {
                   "enabled": False,
    • hermes_cli/config.py
      @@ -159,7 +159,7 @@ def ensure_hermes_home():
           "compression": {
               "enabled": True,
               "threshold": 0.50,
      -        "summary_model": "google/gemini-3-flash-preview",
      +        "summary_model": "",  # empty = use main configured model
               "summary_provider": "auto",
               "summary_base_url": None,
           },
      @@ -1659,7 +1659,8 @@ def show_config():
           print(f"  Enabled:      {'yes' if enabled else 'no'}")
           if enabled:
               print(f"  Threshold:    {compression.get('threshold', 0.50) * 100:.0f}%")
      -        print(f"  Model:        {compression.get('summary_model', 'google/gemini-3-flash-preview')}")
      +        _sm = compression.get('summary_model', '') or '(main model)'
      +        print(f"  Model:        {_sm}")
               comp_provider = compression.get('summary_provider', 'auto')
               if comp_provider != 'auto':
                   print(f"  Provider:     {comp_provider}")
  • #7647 fix(auxiliary): consolidate auxiliary client UX hardening (#7605)

    merged to main

    files: agent/auxiliary_client.py, hermes_cli/main.py, tests/agent/test_auxiliary_client.py, tests/hermes_cli/test_clear_stale_base_url.py

    Show the diff
    • agent/auxiliary_client.py
      @@ -59,6 +59,9 @@
       
       logger = logging.getLogger(__name__)
       
      +# Module-level flag: only warn once per process about stale OPENAI_BASE_URL.
      +_stale_base_url_warned = False
      +
       _PROVIDER_ALIASES = {
           "google": "gemini",
           "google-gemini": "gemini",
      @@ -707,7 +710,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
                   base_url = _to_openai_base_url(
                       _pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url
                   )
      -            model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, "default")
      +            model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)
      +            if model is None:
      +                continue  # skip provider if we don't know a valid aux model
                   logger.debug("Auxiliary text client: %s (%s) via pool", pconfig.name, model)
                   extra = {}
                   if "api.kimi.com" in base_url.lower():
      @@ -726,7 +731,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
               base_url = _to_openai_base_url(
                   str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
               )
      -        model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, "default")
      +        model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)
      +        if model is None:
      +            continue  # skip provider if we don't know a valid aux model
               logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model)
               extra = {}
               if "api.kimi.com" in base_url.lower():
      @@ -1075,11 +1082,12 @@ def _is_connection_error(exc: Exception) -> bool:
       def _try_payment_fallback(
           failed_provider: str,
           task: str = None,
      +    reason: str = "payment error",
       ) -> Tuple[Optional[Any], Optional[str], str]:
      -    """Try alternative providers after a payment/credit error.
      +    """Try alternative providers after a payment/credit or connection error.
       
           Iterates the standard auto-detection chain, skipping the provider that
      -    returned a payment error.
      +    failed.
       
           Returns:
               (client, model, provider_label) or (None, None, "") if no fallback.
      @@ -1105,15 +1113,15 @@ def _try_payment_fallback(
               client, model = try_fn()
               if client is not None:
                   logger.info(
      -                "Auxiliary %s: payment error on %s — falling back to %s (%s)",
      -                task or "call", failed_provider, label, model or "default",
      +                "Auxiliary %s: %s on %s — falling back to %s (%s)",
      +                task or "call", reason, failed_provider, label, model or "default",
                   )
                   return client, model, label
               tried.append(label)
       
           logger.warning(
      -        "Auxiliary %s: payment error on %s and no fallback available (tried: %s)",
      -        task or "call", failed_provider, ", ".join(tried),
      +        "Auxiliary %s: %s on %s and no fallback available (tried: %s)",
      +        task or "call", reason, failed_provider, ", ".join(tried),
           )
           return None, None, ""
       
      @@ -1128,9 +1136,28 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
                provider they already have credentials for — no OpenRouter key needed.
             2. OpenRouter → Nous → custom → Codex → API-key providers (original chain).
           """
      -    global auxiliary_is_nous
      +    global auxiliary_is_nous, _stale_base_url_warned
           auxiliary_is_nous = False  # Reset — _try_nous() will set True if it wins
       
      +    # ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named
      +    #    provider (not 'custom').  This catches the common "env poisoning"
      +    #    scenario where a user switches providers via `hermes model` but the
      +    #    old OPENAI_BASE_URL lingers in ~/.hermes/.env. ──
      +    if not _stale_base_url_warned:
      +        _env_base = os.getenv("OPENAI_BASE_URL", "").strip()
      +        _cfg_provider = _read_main_provider()
      +        if (_env_base and _cfg_provider
      +                and _cfg_provider != "custom"
      +                and not _cfg_provider.startswith("custom:")):
      +            logger.warning(
      +                "OPENAI_BASE_URL is set (%s) but model.provider is '%s'. "
      +                "Auxiliary clients may route to the wrong endpoint. "
      +                "Run: hermes model to reconfigure, or remove "
      +                "OPENAI_BASE_URL from ~/.hermes/.env",
      +                _env_base, _cfg_provider,
      +            )
      +            _stale_base_url_warned = True
      +
           # ── Step 1: non-aggregator main provider → use main model directly ──
           main_provider = _read_main_provider()
           main_model = _read_main_model()
      @@ -1217,6 +1244,7 @@ def resolve_provider_client(
           raw_codex: bool = False,
           explicit_base_url: str = None,
           explicit_api_key: str = None,
      +    api_mode: str = None,
       ) -> Tuple[Optional[Any], Optional[str]]:
           """Central router: given a provider name and optional model, return a
           configured client with the correct auth, base URL, and API format.
      @@ -1240,6 +1268,10 @@ def resolve_provider_client(
                   the main agent loop).
               explicit_base_url: Optional direct OpenAI-compatible endpoint.
               explicit_api_key: Optional API key paired with explicit_base_url.
      +        api_mode: API mode override.  One of "chat_completions",
      +            "codex_responses", or None (auto-detect).  When set to
      +            "codex_responses", the client is wrapped in
      +            CodexAuxiliaryClient to route through the Responses API.
       
           Returns:
               (client, resolved_model) or (None, None) if auth is unavailable.
      @@ -1247,6 +1279,40 @@ def resolve_provider_client(
           # Normalise aliases
           provider = _normalize_aux_provider(provider)
       
      +    def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool:
      +        """Decide if a plain OpenAI client should be wrapped for Responses API.
      +
      +        Returns True when api_mode is explicitly "codex_responses", or when
      +        auto-detection (api.openai.com + codex-family model) suggests it.
      +        Already-wrapped clients (CodexAuxiliaryClient) are skipped.
      +        """
      +        if isinstance(client_obj, CodexAuxiliaryClient):
      +            return False
      +        if raw_codex:
      +            return False
      +        if api_mode == "codex_responses":
      +            return True
      +        # Auto-detect: api.openai.com + codex model name pattern
      +        if api_mode and api_mode != "codex_responses":
      +            return False  # explicit non-codex mode
      +        normalized_base = (base_url_str or "").strip().lower()
      +        if "api.openai.com" in normalized_base and "openrouter" not in normalized_base:
      +            model_lower = (model_str or "").lower()
      +            if "codex" in model_lower:
      +                return True
      +        return False
      +
      +    def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""):
      +        """Wrap a plain OpenAI client in CodexAuxiliaryClient if Responses API is needed."""
      +        if _needs_codex_wrap(client_obj, base_url_str, final_model_str):
      +            logger.debug(
      +                "resolve_provider_client: wrapping client in CodexAuxiliaryClient "
      +                "(api_mode=%s, model=%s, base_url=%s)",
      +                api_mode or "auto-detected", final_model_str,
      +                base_url_str[:60] if base_url_str else "")
      +            return CodexAuxiliaryClient(client_obj, final_model_str)
      +        return client_obj
      +
           # ── Auto: try all providers in priority order ────────────────────
           if provider == "auto":
               client, resolved = _resolve_auto()
      @@ -1336,6 +1402,7 @@ def resolve_provider_client(
                       from hermes_cli.models import copilot_default_headers
                       extra["default_headers"] = copilot_default_headers()
                   client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base, **extra)
      +            client = _wrap_if_needed(client, final_model, custom_base)
                   return (_to_async_client(client, final_model) if async_mode
                           else (client, final_model))
               # Try custom first, then codex, then API-key providers
      @@ -1344,6 +1411,8 @@ def resolve_provider_client(
                   client, default = try_fn()
                   if client is not None:
                       final_model = _normalize_resolved_model(model or default, provider)
      +                _cbase = str(getattr(client, "base_url", "") or "")
      +                client = _wrap_if_needed(client, final_model, _cbase)
                       return (_to_async_client(client, final_model) if async_mode
                               else (client, final_model))
               logger.warning("resolve_provider_client: custom/main requested "
      @@ -1363,6 +1432,7 @@ def resolve_provider_client(
                           provider,
                       )
                       client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base)
      +                client = _wrap_if_needed(client, final_model, custom_base)
                       logger.debug(
                           "resolve_provider_client: named custom provider %r (%s)",
                           provider, final_model)
      @@ -1442,6 +1512,11 @@ def resolve_provider_client(
                   except ImportError:
                       pass
       
      +        # Honor api_mode for any API-key provider (e.g. direct OpenAI with
      +        # codex-family models).  The copilot-specific wrapping above handles
      +        # copilot; this covers the general case (#6800).
      +        client = _wrap_if_needed(client, final_model, base_url)
      +
               logger.debug("resolve_provider_client: %s (%s)", provider, final_model)
               return (_to_async_client(client, final_model) if async_mode
                       else (client, final_model))
      @@ -1474,12 +1549,13 @@ def get_text_auxiliary_client(task: str = "") -> Tuple[Optional[OpenAI], Optiona
           Callers may override the returned model with a per-task env var
           (e.g. CONTEXT_COMPRESSION_MODEL, AUXILIARY_WEB_EXTRACT_MODEL).
           """
      -    provider, model, base_url, api_key = [redacted:secret-assignment] or None)
      +    provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(task or None)
           return resolve_provider_client(
               provider,
               model=model,
               explicit_base_url=base_url,
               explicit_api_key=[redacted:secret-assignment]
      +        api_mode=api_mode,
           )
       
       
      @@ -1490,13 +1566,14 @@ def get_async_text_auxiliary_client(task: str = ""):
           (AsyncCodexAuxiliaryClient, model) which wraps the Responses API.
           Returns (None, None) when no provider is available.
           """
      -    provider, model, base_url, api_key = [redacted:secret-assignment] or None)
      +    provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(task or None)
           return resolve_provider_client(
               provider,
               model=model,
               async_mode=True,
               explicit_base_url=base_url,
               explicit_api_key=[redacted:secret-assignment]
      +        api_mode=api_mode,
           )
       
       
      @@ -1569,7 +1646,7 @@ def resolve_vision_provider_client(
           backends, so users can intentionally force experimental providers. Auto mode
           stays conservative and only tries vision backends known to work today.
           """
      -    requested, resolved_model, resolved_base_url, resolved_api_key = [redacted:secret-assignment]
      +    requested, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
               "vision", provider, model, base_url, api_key
           )
           requested = _normalize_vision_provider(requested)
      @@ -1785,12 +1862,30 @@ def cleanup_stale_async_clients() -> None:
                   del _client_cache[key]
       
       
      +def _is_openrouter_client(client: Any) -> bool:
      +    for obj in (client, getattr(client, "_client", None), getattr(client, "client", None)):
      +        if obj and "openrouter" in str(getattr(obj, "base_url", "") or "").lower():
      +            return True
      +    return False
      +
      +
      +def _compat_model(client: Any, model: Optional[str], cached_default: Optional[str]) -> Optional[str]:
      +    """Drop OpenRouter-format model slugs (with '/') for non-OpenRouter clients.
      +
      +    Mirrors the guard in resolve_provider_client() which is skipped on cache hits.
      +    """
      +    if model and "/" in model and not _is_openrouter_client(client):
      +        return cached_default
      +    return model or cached_default
      +
      +
       def _get_cached_client(
           provider: str,
           model: str = None,
           async_mode: bool = False,
           base_url: str = None,
           api_key: str = None,
      +    api_mode: str = None,
       ) -> Tuple[Optional[Any], Optional[str]]:
           """Get or create a cached client for the given provider.
       
      @@ -1814,7 +1909,7 @@ def _get_cached_client(
                   loop_id = id(current_loop)
               except RuntimeError:
                   pass
      -    cache_key = (provider, async_mode, base_url or "", api_key or "", loop_id)
      +    cache_key = (provider, async_mode, base_url or "", api_key or "", api_mode or "", loop_id)
           with _client_cache_lock:
               if cache_key in _client_cache:
                   cached_client, cached_default, cached_loop = _client_cache[cache_key]
      @@ -1826,9 +1921,11 @@ def _get_cached_client(
                           _force_close_async_httpx(cached_client)
                           del _client_cache[cache_key]
                       else:
      -                    return cached_client, model or cached_default
      +                    effective = _compat_model(cached_client, model, cached_default)
      +                    return cached_client, effective
                   else:
      -                return cached_client, model or cached_default
      +                effective = _compat_model(cached_client, model, cached_default)
      +                return cached_client, effective
           # Build outside the lock
           client, default_model = resolve_provider_client(
               provider,
      @@ -1836,6 +1933,7 @@ def _get_cached_client(
               async_mode,
               explicit_base_url=base_url,
               explicit_api_key=[redacted:secret-assignment]
      +        api_mode=api_mode,
           )
           if client is not None:
               # For async clients, remember which loop they were created on so we
      @@ -1855,7 +1953,7 @@ def _resolve_task_provider_model(
           model: str = None,
           base_url: str = None,
           api_key: str = None,
      -) -> Tuple[str, Optional[str], Optional[str], Optional[str]]:
      +) -> Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]:
           """Determine provider + model for a call.
       
           Priority:
      @@ -1864,15 +1962,17 @@ def _resolve_task_provider_model(
             3. Config file (auxiliary.{task}.* or compression.*)
             4. "auto" (full auto-detection chain)
       
      -    Returns (provider, model, base_url, api_key) where model may be None
      -    (use provider default). When base_url is set, provider is forced to
      -    "custom" and the task uses that direct endpoint.
      +    Returns (provider, model, base_url, api_key, api_mode) where model may
      +    be None (use provider default). When base_url is set, provider is forced
      +    to "custom" and the task uses that direct endpoint. api_mode is one of
      +    "chat_completions", "codex_responses", or None (auto-detect).
           """
           config = {}
           cfg_provider = None
           cfg_model = None
           cfg_base_url = None
           cfg_api_key = None
      +    cfg_api_mode = None
       
           if task:
               try:
      @@ -1889,6 +1989,7 @@ def _resolve_task_provider_model(
               cfg_model = str(task_config.get("model", "")).strip() or None
               cfg_base_url = str(task_config.get("base_url", "")).strip() or None
               cfg_api_key = [redacted:secret-assignment]"api_key", "")).strip() or None
      +        cfg_api_mode = str(task_config.get("api_mode", "")).strip() or None
       
               # Backwards compat: compression section has its own keys.
               # The auxiliary.compression defaults to provider="auto", so treat
      @@ -1902,30 +2003,32 @@ def _resolve_task_provider_model(
                       cfg_base_url = cfg_base_url or _sbu.strip() or None
       
           env_model = _get_auxiliary_env_override(task, "MODEL") if task else None
      +    env_api_mode = _get_auxiliary_env_override(task, "API_MODE") if task else None
           resolved_model = model or env_model or cfg_model
      +    resolved_api_mode = env_api_mode or cfg_api_mode
       
           if base_url:
      -        return "custom", resolved_model, base_url, api_key
      +        return "custom", resolved_model, base_url, api_key, resolved_api_mode
           if provider:
      -        return provider, resolved_model, base_url, api_key
      +        return provider, resolved_model, base_url, api_key, resolved_api_mode
       
           if task:
               env_base_url = _get_auxiliary_env_override(task, "BASE_URL")
               env_api_key = [redacted:secret-assignment] "API_KEY")
               if env_base_url:
      -            return "custom", resolved_model, env_base_url, env_api_key or cfg_api_key
      +            return "custom", resolved_model, env_base_url, env_api_key or cfg_api_key, resolved_api_mode
       
               env_provider = _get_auxiliary_provider(task)
               if env_provider != "auto":
      -            return env_provider, resolved_model, None, None
      +            return env_provider, resolved_model, None, None, resolved_api_mode
       
               if cfg_base_url:
      -            return "custom", resolved_model, cfg_base_url, cfg_api_key
      +            return "custom", resolved_model, cfg_base_url, cfg_api_key, resolved_api_mode
               if cfg_provider and cfg_provider != "auto":
      -            return cfg_provider, resolved_model, None, None
      -        return "auto", resolved_model, None, None
      +            return cfg_provider, resolved_model, None, None, resolved_api_mode
      +        return "auto", resolved_model, None, None, resolved_api_mode
       
      -    return "auto", resolved_model, None, None
      +    return "auto", resolved_model, None, None, resolved_api_mode
       
       
       _DEFAULT_AUX_TIMEOUT = 30.0
      @@ -1997,6 +2100,37 @@ def _build_call_kwargs(
           return kwargs
       
       
      +def _validate_llm_response(response: Any, task: str = None) -> Any:
      +    """Validate that an LLM response has the expected .choices[0].message shape.
      +
      +    Fails fast with a clear error instead of letting malformed payloads
      +    propagate to downstream consumers where they crash with misleading
      +    AttributeError (e.g. "'str' object has no attribute 'choices'").
      +
      +    See #7264.
      +    """
      +    if response is None:
      +        raise RuntimeError(
      +            f"Auxiliary {task or 'call'}: LLM returned None response"
      +        )
      +    # Allow SimpleNamespace responses from adapters (CodexAuxiliaryClient,
      +    # AnthropicAuxiliaryClient) — they have .choices[0].message.
      +    try:
      +        choices = response.choices
      +        if not choices or not hasattr(choices[0], "message"):
      +            raise AttributeError("missing choices[0].message")
      +    except (AttributeError, TypeError, IndexError) as exc:
      +        response_type = type(response).__name__
      +        response_preview = str(response)[:120]
      +        raise RuntimeError(
      +            f"Auxiliary {task or 'call'}: LLM returned invalid response "
      +            f"(type={response_type}): {response_preview!r}. "
      +            f"Expected object with .choices[0].message — check provider "
      +            f"adapter or custom endpoint compatibility."
      +        ) from exc
      +    return response
      +
      +
       def call_llm(
           task: str = None,
           *,
      @@ -2035,7 +2169,7 @@ def call_llm(
           Raises:
               RuntimeError: If no provider is configured.
           """
      -    resolved_provider, resolved_model, resolved_base_url, resolved_api_key = [redacted:secret-assignment]
      +    resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
               task, provider, model, base_url, api_key)
       
           if task == "vision":
      @@ -2068,6 +2202,7 @@ def call_llm(
                   resolved_model,
                   base_url=resolved_base_url,
                   api_key=[redacted:secret-assignment]
      +            api_mode=resolved_api_mode,
               )
               if client is None:
                   # When the user explicitly chose a non-OpenRouter provider but no
      @@ -2111,18 +2246,20 @@ def call_llm(
       
           # Handle max_tokens vs max_completion_tokens retry, then payment fallback.
           try:
      -        return client.chat.completions.create(**kwargs)
      +        return _validate_llm_response(
      +            client.chat.completions.create(**kwargs), task)
           except Exception as first_err:
               err_str = str(first_err)
               if "max_tokens" in err_str or "unsupported_parameter" in err_str:
                   kwargs.pop("max_tokens", None)
                   kwargs["max_completion_tokens"] = max_tokens
                   try:
      -                return client.chat.completions.create(**kwargs)
      +                return _validate_llm_response(
      +                    client.chat.completions.create(**kwargs), task)
                   except Exception as retry_err:
      -                # If the max_tokens retry also hits a payment error,
      -                # fall through to the payment fallback below.
      -                if not _is_payment_error(retry_err):
      +                # If the max_tokens retry also hits a payment or connection
      +                # error, fall through to the fallback chain below.
      +                if not (_is_payment_error(retry_err) or _is_connection_error(retry_err)):
                           raise
                       first_err = retry_err
       
      @@ -2139,19 +2276,24 @@ def call_llm(
               # and providers the user never configured that got picked up by
               # the auto-detection chain.
               should_fallback = _is_payment_error(first_err) or _is_connection_error(first_err)
      -        if should_fallback:
      +        # Only try alternative providers when the user didn't explicitly
      +        # configure this task's provider.  Explicit provider = hard constraint;
      +        # auto (the default) = best-effort fallback chain.  (#7559)
      +        is_auto = resolved_provider in ("auto", "", None)
      +        if should_fallback and is_auto:
                   reason = "payment error" if _is_payment_error(first_err) else "connection error"
                   logger.info("Auxiliary %s: %s on %s (%s), trying fallback",
                               task or "call", reason, resolved_provider, first_err)
                   fb_client, fb_model, fb_label = _try_payment_fallback(
      -                resolved_provider, task)
      +                resolved_provider, task, reason=reason)
                   if fb_client is not None:
                       fb_kwargs = _build_call_kwargs(
                           fb_label, fb_model, messages,
                           temperature=temperature, max_tokens=[redacted:secret-assignment]
                           tools=tools, timeout=effective_timeout,
                           extra_body=extra_body)
      -                return fb_client.chat.completions.create(**fb_kwargs)
      +                return _validate_llm_response(
      +                    fb_client.chat.completions.create(**fb_kwargs), task)
               raise
       
       
      @@ -2229,7 +2371,7 @@ async def async_call_llm(
       
           Same as call_llm() but async. See call_llm() for full documentation.
           """
      -    resolved_provider, resolved_model, resolved_base_url, resolved_api_key = [redacted:secret-assignment]
      +    resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
               task, provider, model, base_url, api_key)
       
           if task == "vision":
      @@ -2263,6 +2405,7 @@ async def async_call_llm(
                   async_mode=True,
                   base_url=resolved_base_url,
                   api_key=[redacted:secret-assignment]
      +            api_mode=resolved_api_mode,
               )
               if client is None:
                   _explicit = (resolved_provider or "").strip().lower()
      @@ -2273,11 +2416,9 @@ async def async_call_llm(
                           f"variable, or switch to a different provider with `hermes model`."
                       )
                   if not resolved_base_url:
      -                logger.warning("Provider %s unavailable, falling back to openrouter",
      -                               resolved_provider)
      -                client, final_model = _get_cached_client(
      -                    "openrouter", resolved_model or _OPENROUTER_MODEL,
      -                    async_mode=True)
      +                logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
      +                            task or "call", resolved_provider)
      +                client, final_model = _get_cached_client("auto", async_mode=True)
               if client is None:
                   raise RuntimeError(
                       f"No LLM provider configured for task={task} provider={resolved_provider}. "
      @@ -2292,11 +2433,42 @@ async def async_call_llm(
               base_url=resolved_base_url)
       
           try:
      -        return await client.chat.completions.create(**kwargs)
      +        return _validate_llm_response(
      +            await client.chat.completions.create(**kwargs), task)
           except Exception as first_err:
               err_str = str(first_err)
               if "max_tokens" in err_str or "unsupported_parameter" in err_str:
                   kwargs.pop("max_tokens", None)
                   kwargs["max_completion_tokens"] = max_tokens
      -            return await client.chat.completions.create(**kwargs)
      +            try:
      +                return _validate_llm_response(
      +                    await client.chat.completions.create(**kwargs), task)
      +            except Exception as retry_err:
      +                # If the max_tokens retry also hits a payment or connection
      +                # error, fall through to the fallback chain below.
      +                if not (_is_payment_error(retry_err) or _is_connection_error(retry_err)):
      +                    raise
      +                first_err = retry_err
      +
      +        # ── Payment / connection fallback (mirrors sync call_llm) ─────
      +        should_fallback = _is_payment_error(first_err) or _is_connection_error(first_err)
      +        is_auto = resolved_provider in ("auto", "", None)
      +        if should_fallback and is_auto:
      +            reason = "payment error" if _is_payment_error(first_err) else "connection error"
      +            logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback",
      +                        task or "call", reason, resolved_provider, first_err)
      +            fb_client, fb_model, fb_label = _try_payment_fallback(
      +                resolved_provider, task, reason=reason)
      +            if fb_client is not None:
      +                fb_kwargs = _build_call_kwargs(
      +                    fb_label, fb_model, messages,
      +                    temperature=temperature, max_tokens=[redacted:secret-assignment]
      +                    tools=tools, timeout=effective_timeout,
      +                    extra_body=extra_body)
      +                # Convert sync fallback client to async
      +                async_fb, async_fb_model = _to_async_client(fb_client, fb_model or "")
      +                if async_fb_model and async_fb_model != fb_kwargs.get("model"):
      +                    fb_kwargs["model"] = async_fb_model
      +                return _validate_llm_response(
      +                    await async_fb.chat.completions.create(**fb_kwargs), task)
               raise
    • hermes_cli/main.py
      @@ -1080,6 +1080,42 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:
           elif selected_provider in ("gemini", "zai", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface"):
               _model_flow_api_key_provider(config, selected_provider, current_model)
       
      +    # ── Post-switch cleanup: clear stale OPENAI_BASE_URL ──────────────
      +    # When the user switches to a named provider (anything except "custom"),
      +    # a leftover OPENAI_BASE_URL in ~/.hermes/.env can poison auxiliary
      +    # clients that use provider:auto. Clear it proactively.  (#5161)
      +    if selected_provider not in ("custom", "cancel", "remove-custom") \
      +            and not selected_provider.startswith("custom:"):
      +        _clear_stale_openai_base_url()
      +
      +
      +def _clear_stale_openai_base_url():
      +    """Remove OPENAI_BASE_URL from ~/.hermes/.env if the active provider is not 'custom'.
      +
      +    After a provider switch, a leftover OPENAI_BASE_URL causes auxiliary
      +    clients (compression, vision, delegation) with provider:auto to route
      +    requests to the old custom endpoint instead of the newly selected
      +    provider.  See issue #5161.
      +    """
      +    from hermes_cli.config import get_env_value, save_env_value, load_config
      +
      +    cfg = load_config()
      +    model_cfg = cfg.get("model", {})
      +    if isinstance(model_cfg, dict):
      +        provider = (model_cfg.get("provider") or "").strip().lower()
      +    else:
      +        provider = ""
      +
      +    if provider == "custom" or not provider:
      +        return  # custom provider legitimately uses OPENAI_BASE_URL
      +
      +    stale_url = get_env_value("OPENAI_BASE_URL")
      +    if stale_url:
      +        save_env_value("OPENAI_BASE_URL", "")
      +        print(f"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url[:40]}...)"
      +              if len(stale_url) > 40
      +              else f"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url})")
      +
       
       def _prompt_provider_choice(choices, *, default=0):
           """Show provider selection menu with curses arrow-key navigation.
    • tests/agent/test_auxiliary_client.py
      @@ -1,9 +1,10 @@
       """Tests for agent.auxiliary_client resolution chain, provider overrides, and model overrides."""
       
       import json
      +import logging
       import os
       from pathlib import Path
      -from unittest.mock import patch, MagicMock
      +from unittest.mock import patch, MagicMock, AsyncMock
       
       import pytest
       
      @@ -14,6 +15,7 @@
           resolve_provider_client,
           auxiliary_max_tokens_param,
           call_llm,
      +    async_call_llm,
           _read_codex_access_token,
           _get_auxiliary_provider,
           _get_provider_chain,
      @@ -1122,8 +1124,8 @@ def _make_402_error(self, msg="Payment Required: insufficient credits"):
               exc.status_code = 402
               return exc
       
      -    def test_402_triggers_fallback(self, monkeypatch):
      -        """When the primary provider returns 402, call_llm tries the next one."""
      +    def test_402_triggers_fallback_when_auto(self, monkeypatch):
      +        """When provider is auto and returns 402, call_llm tries the next one."""
               monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
       
               primary_client = MagicMock()
      @@ -1136,7 +1138,7 @@ def test_402_triggers_fallback(self, monkeypatch):
               with patch("agent.auxiliary_client._get_cached_client",
                           return_value=(primary_client, "google/gemini-3-flash-preview")), \
                    patch("agent.auxiliary_client._resolve_task_provider_model",
      -                    return_value=("openrouter", "google/gemini-3-flash-preview", None, None)), \
      +                    return_value=("auto", "google/gemini-3-flash-preview", None, None, None)), \
                    patch("agent.auxiliary_client._try_payment_fallback",
                           return_value=(fallback_client, "gpt-5.2-codex", "openai-codex")) as mock_fb:
                   result = call_llm(
      @@ -1145,13 +1147,62 @@ def test_402_triggers_fallback(self, monkeypatch):
                   )
       
               assert result is fallback_response
      -        mock_fb.assert_called_once_with("openrouter", "compression")
      +        mock_fb.assert_called_once_with("auto", "compression", reason="payment error")
               # Fallback call should use the fallback model
               fb_kwargs = fallback_client.chat.completions.create.call_args.kwargs
               assert fb_kwargs["model"] == "gpt-5.2-codex"
       
      +    def test_402_no_fallback_when_explicit_provider(self, monkeypatch):
      +        """When provider is explicitly configured (not auto), 402 should NOT fallback (#7559)."""
      +        monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
      +
      +        primary_client = MagicMock()
      +        primary_client.chat.completions.create.side_effect = self._make_402_error()
      +
      +        with patch("agent.auxiliary_client._get_cached_client",
      +                    return_value=(primary_client, "local-model")), \
      +             patch("agent.auxiliary_client._resolve_task_provider_model",
      +                    return_value=("custom", "local-model", None, None, None)), \
      +             patch("agent.auxiliary_client._try_payment_fallback") as mock_fb:
      +            with pytest.raises(Exception, match="insufficient credits"):
      +                call_llm(
      +                    task="compression",
      +                    messages=[{"role": "user", "content": "hello"}],
      +                )
      +
      +        # Fallback should NOT be attempted when provider is explicit
      +        mock_fb.assert_not_called()
      +
      +    def test_connection_error_triggers_fallback_when_auto(self, monkeypatch):
      +        """Connection errors also trigger fallback when provider is auto."""
      +        monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
      +
      +        primary_client = MagicMock()
      +        conn_err = Exception("Connection refused")
      +        conn_err.status_code = None
      +        primary_client.chat.completions.create.side_effect = conn_err
      +
      +        fallback_client = MagicMock()
      +        fallback_response = MagicMock()
      +        fallback_client.chat.completions.create.return_value = fallback_response
      +
      +        with patch("agent.auxiliary_client._get_cached_client",
      +                    return_value=(primary_client, "model")), \
      +             patch("agent.auxiliary_client._resolve_task_provider_model",
      +                    return_value=("auto", "model", None, None, None)), \
      +             patch("agent.auxiliary_client._is_connection_error", return_value=True), \
      +             patch("agent.auxiliary_client._try_payment_fallback",
      +                    return_value=(fallback_client, "fb-model", "nous")) as mock_fb:
      +            result = call_llm(
      +                task="compression",
      +                messages=[{"role": "user", "content": "hello"}],
      +            )
      +
      +        assert result is fallback_response
      +        mock_fb.assert_called_once_with("auto", "compression", reason="connection error")
      +
           def test_non_payment_error_not_caught(self, monkeypatch):
      -        """Non-payment errors (500, connection, etc.) should NOT trigger fallback."""
      +        """Non-payment/non-connection errors (500) should NOT trigger fallback."""
               monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
       
               primary_client = MagicMock()
      @@ -1162,7 +1213,7 @@ def test_non_payment_error_not_caught(self, monkeypatch):
               with patch("agent.auxiliary_client._get_cached_client",
                           return_value=(primary_client, "google/gemini-3-flash-preview")), \
                    patch("agent.auxiliary_client._resolve_task_provider_model",
      -                    return_value=("openrouter", "google/gemini-3-flash-preview", None, None)):
      +                    return_value=("auto", "google/gemini-3-flash-preview", None, None, None)):
                   with pytest.raises(Exception, match="Internal Server Error"):
                       call_llm(
                           task="compression",
      @@ -1179,7 +1230,7 @@ def test_402_with_no_fallback_reraises(self, monkeypatch):
               with patch("agent.auxiliary_client._get_cached_client",
                           return_value=(primary_client, "google/gemini-3-flash-preview")), \
                    patch("agent.auxiliary_client._resolve_task_provider_model",
      -                    return_value=("openrouter", "google/gemini-3-flash-preview", None, None)), \
      +                    return_value=("auto", "google/gemini-3-flash-preview", None, None, None)), \
                    patch("agent.auxiliary_client._try_payment_fallback",
                           return_value=(None, None, "")):
                   with pytest.raises(Exception, match="insufficient credits"):
      @@ -1229,3 +1280,283 @@ def mock_try_anthropic():
       
           assert "anthropic" not in called, \
               "_try_anthropic() should not be called when anthropic is not explicitly configured"
      +
      +
      +# ---------------------------------------------------------------------------
      +# model="default" elimination (#7512)
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestModelDefaultElimination:
      +    """_resolve_api_key_provider must skip providers without known aux models."""
      +
      +    def test_unknown_provider_skipped(self, monkeypatch):
      +        """Providers not in _API_KEY_PROVIDER_AUX_MODELS are skipped, not sent model='default'."""
      +        from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
      +
      +        # Verify our known providers have entries
      +        assert "gemini" in _API_KEY_PROVIDER_AUX_MODELS
      +        assert "kimi-coding" in _API_KEY_PROVIDER_AUX_MODELS
      +
      +        # A random provider_id not in the dict should return None
      +        assert _API_KEY_PROVIDER_AUX_MODELS.get("totally-unknown-provider") is None
      +
      +    def test_known_provider_gets_real_model(self):
      +        """Known providers get a real model name, not 'default'."""
      +        from agent.auxiliary_client import _API_KEY_PROVIDER_AUX_MODELS
      +
      +        for provider_id, model in _API_KEY_PROVIDER_AUX_MODELS.items():
      +            assert model != "default", f"{provider_id} should not map to 'default'"
      +            assert isinstance(model, str) and model.strip(), \
      +                f"{provider_id} should have a non-empty model string"
      +
      +
      +# ---------------------------------------------------------------------------
      +# _try_payment_fallback reason parameter (#7512 bug 3)
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestTryPaymentFallbackReason:
      +    """_try_payment_fallback uses the reason parameter in log messages."""
      +
      +    def test_reason_parameter_passed_through(self, monkeypatch):
      +        """The reason= parameter is accepted without error."""
      +        from agent.auxiliary_client import _try_payment_fallback
      +
      +        # Mock the provider chain to return nothing
      +        monkeypatch.setattr(
      +            "agent.auxiliary_client._get_provider_chain",
      +            lambda: [],
      +        )
      +        monkeypatch.setattr(
      +            "agent.auxiliary_client._read_main_provider",
      +            lambda: "",
      +        )
      +
      +        client, model, label = _try_payment_fallback(
      +            "openrouter", task="compression", reason="connection error"
      +        )
      +        assert client is None
      +        assert label == ""
      +
      +
      +# ---------------------------------------------------------------------------
      +# _is_connection_error coverage
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestIsConnectionError:
      +    """Tests for _is_connection_error detection."""
      +
      +    def test_connection_refused(self):
      +        from agent.auxiliary_client import _is_connection_error
      +        err = Exception("Connection refused")
      +        assert _is_connection_error(err) is True
      +
      +    def test_timeout(self):
      +        from agent.auxiliary_client import _is_connection_error
      +        err = Exception("Request timed out.")
      +        assert _is_connection_error(err) is True
      +
      +    def test_dns_failure(self):
      +        from agent.auxiliary_client import _is_connection_error
      +        err = Exception("Name or service not known")
      +        assert _is_connection_error(err) is True
      +
      +    def test_normal_api_error_not_connection(self):
      +        from agent.auxiliary_client import _is_connection_error
      +        err = Exception("Bad Request: invalid model")
      +        err.status_code = 400
      +        assert _is_connection_error(err) is False
      +
      +    def test_500_not_connection(self):
      +        from agent.auxiliary_client import _is_connection_error
      +        err = Exception("Internal Server Error")
      +        err.status_code = 500
      +        assert _is_connection_error(err) is False
      +
      +
      +# ---------------------------------------------------------------------------
      +# async_call_llm payment / connection fallback (#7512 bug 2)
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestAsyncCallLlmFallback:
      +    """async_call_llm mirrors call_llm fallback behavior."""
      +
      +    def _make_402_error(self, msg="Payment Required: insufficient credits"):
      +        exc = Exception(msg)
      +        exc.status_code = 402
      +        return exc
      +
      +    @pytest.mark.asyncio
      +    async def test_402_triggers_async_fallback_when_auto(self, monkeypatch):
      +        """When provider is auto and returns 402, async_call_llm tries fallback."""
      +        monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
      +
      +        primary_client = MagicMock()
      +        primary_client.chat.completions.create = AsyncMock(
      +            side_effect=self._make_402_error())
      +
      +        # Fallback client (sync) returned by _try_payment_fallback
      +        fb_sync_client = MagicMock()
      +        fb_async_client = MagicMock()
      +        fb_response = MagicMock()
      +        fb_async_client.chat.completions.create = AsyncMock(return_value=fb_response)
      +
      +        with patch("agent.auxiliary_client._get_cached_client",
      +                    return_value=(primary_client, "google/gemini-3-flash-preview")), \
      +             patch("agent.auxiliary_client._resolve_task_provider_model",
      +                    return_value=("auto", "google/gemini-3-flash-preview", None, None, None)), \
      +             patch("agent.auxiliary_client._try_payment_fallback",
      +                    return_value=(fb_sync_client, "gpt-5.2-codex", "openai-codex")) as mock_fb, \
      +             patch("agent.auxiliary_client._to_async_client",
      +                    return_value=(fb_async_client, "gpt-5.2-codex")):
      +            result = await async_call_llm(
      +                task="compression",
      +                messages=[{"role": "user", "content": "hello"}],
      +            )
      +
      +        assert result is fb_response
      +        mock_fb.assert_called_once_with("auto", "compression", reason="payment error")
      +
      +    @pytest.mark.asyncio
      +    async def test_402_no_async_fallback_when_explicit(self, monkeypatch):
      +        """When provider is explicit, 402 should NOT trigger async fallback."""
      +        monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
      +
      +        primary_client = MagicMock()
      +        primary_client.chat.completions.create = AsyncMock(
      +            side_effect=self._make_402_error())
      +
      +        with patch("agent.auxiliary_client._get_cached_client",
      +                    return_value=(primary_client, "local-model")), \
      +             patch("agent.auxiliary_client._resolve_task_provider_model",
      +                    return_value=("custom", "local-model", None, None, None)), \
      +             patch("agent.auxiliary_client._try_payment_fallback") as mock_fb:
      +            with pytest.raises(Exception, match="insufficient credits"):
      +                await async_call_llm(
      +                    task="compression",
      +                    messages=[{"role": "user", "content": "hello"}],
      +                )
      +
      +        mock_fb.assert_not_called()
      +
      +    @pytest.mark.asyncio
      +    async def test_connection_error_triggers_async_fallback(self, monkeypatch):
      +        """Connection errors trigger async fallback when provider is auto."""
      +        monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
      +
      +        primary_client = MagicMock()
      +        conn_err = Exception("Connection refused")
      +        conn_err.status_code = None
      +        primary_client.chat.completions.create = AsyncMock(side_effect=conn_err)
      +
      +        fb_sync_client = MagicMock()
      +        fb_async_client = MagicMock()
      +        fb_response = MagicMock()
      +        fb_async_client.chat.completions.create = AsyncMock(return_value=fb_response)
      +
      +        with patch("agent.auxiliary_client._get_cached_client",
      +                    return_value=(primary_client, "model")), \
      +             patch("agent.auxiliary_client._resolve_task_provider_model",
      +                    return_value=("auto", "model", None, None, None)), \
      +             patch("agent.auxiliary_client._is_connection_error", return_value=True), \
      +             patch("agent.auxiliary_client._try_payment_fallback",
      +                    return_value=(fb_sync_client, "fb-model", "nous")) as mock_fb, \
      +             patch("agent.auxiliary_client._to_async_client",
      +                    return_value=(fb_async_client, "fb-model")):
      +            result = await async_call_llm(
      +                task="compression",
      +                messages=[{"role": "user", "content": "hello"}],
      +            )
      +
      +        assert result is fb_response
      +        mock_fb.assert_called_once_with("auto", "compression", reason="connection error")
      +class TestStaleBaseUrlWarning:
      +    """_resolve_auto() warns when OPENAI_BASE_URL conflicts with config provider (#5161)."""
      +
      +    def test_warns_when_openai_base_url_set_with_named_provider(self, monkeypatch, caplog):
      +        """Warning fires when OPENAI_BASE_URL is set but provider is a named provider."""
      +        import agent.auxiliary_client as mod
      +        # Reset the module-level flag so the warning fires
      +        monkeypatch.setattr(mod, "_stale_base_url_warned", False)
      +        monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:11434/v1")
      +        monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
      +
      +        with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
      +             patch("agent.auxiliary_client._read_main_model", return_value="google/gemini-flash"), \
      +             caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
      +            _resolve_auto()
      +
      +        assert any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \
      +            "Expected a warning about stale OPENAI_BASE_URL"
      +        assert mod._stale_base_url_warned is True
      +
      +    def test_no_warning_when_provider_is_custom(self, monkeypatch, caplog):
      +        """No warning when the provider is 'custom' — OPENAI_BASE_URL is expected."""
      +        import agent.auxiliary_client as mod
      +        monkeypatch.setattr(mod, "_stale_base_url_warned", False)
      +        monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:11434/v1")
      +        monkeypatch.setenv("OPENAI_API_KEY", "test-key")
      +
      +        with patch("agent.auxiliary_client._read_main_provider", return_value="custom"), \
      +             patch("agent.auxiliary_client._read_main_model", return_value="llama3"), \
      +             patch("agent.auxiliary_client._resolve_custom_runtime",
      +                   return_value=("http://localhost:11434/v1", "test-key", None)), \
      +             patch("agent.auxiliary_client.OpenAI") as mock_openai, \
      +             caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
      +            mock_openai.return_value = MagicMock()
      +            _resolve_auto()
      +
      +        assert not any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \
      +            "Should NOT warn when provider is 'custom'"
      +
      +    def test_no_warning_when_provider_is_named_custom(self, monkeypatch, caplog):
      +        """No warning when the provider is 'custom:myname' — base_url comes from config."""
      +        import agent.auxiliary_client as mod
      +        monkeypatch.setattr(mod, "_stale_base_url_warned", False)
      +        monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:11434/v1")
      +        monkeypatch.setenv("OPENAI_API_KEY", "test-key")
      +
      +        with patch("agent.auxiliary_client._read_main_provider", return_value="custom:ollama-local"), \
      +             patch("agent.auxiliary_client._read_main_model", return_value="llama3"), \
      +             patch("agent.auxiliary_client.resolve_provider_client",
      +                   return_value=(MagicMock(), "llama3")), \
      +             caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
      +            _resolve_auto()
      +
      +        assert not any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \
      +            "Should NOT warn when provider is 'custom:*'"
      +
      +    def test_no_warning_when_openai_base_url_not_set(self, monkeypatch, caplog):
      +        """No warning when OPENAI_BASE_URL is absent."""
      +        import agent.auxiliary_client as mod
      +        monkeypatch.setattr(mod, "_stale_base_url_warned", False)
      +        monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
      +        monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
      +
      +        with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
      +             patch("agent.auxiliary_client._read_main_model", return_value="google/gemini-flash"), \
      +             caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
      +            _resolve_auto()
      +
      +        assert not any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \
      +            "Should NOT warn when OPENAI_BASE_URL is not set"
      +
      +    def test_warning_only_fires_once(self, monkeypatch, caplog):
      +        """Warning is suppressed after the first invocation."""
      +        import agent.auxiliary_client as mod
      +        monkeypatch.setattr(mod, "_stale_base_url_warned", False)
      +        monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:11434/v1")
      +        monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
      +
      +        with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \
      +             patch("agent.auxiliary_client._read_main_model", return_value="google/gemini-flash"), \
      +             caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
      +            _resolve_auto()
      +            caplog.clear()
      +            _resolve_auto()
      +
      +        assert not any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \
      +            "Warning should not fire a second time"
    • tests/hermes_cli/test_clear_stale_base_url.py
      @@ -0,0 +1,75 @@
      +"""Tests for _clear_stale_openai_base_url() cleanup after provider switch (#5161)."""
      +
      +from __future__ import annotations
      +
      +from unittest.mock import patch
      +
      +from hermes_cli.config import load_config, save_config, save_env_value, get_env_value
      +
      +
      +def _write_provider(provider: str, model: str = "test-model"):
      +    """Helper: write a provider + model to config.yaml."""
      +    cfg = load_config()
      +    model_cfg = cfg.get("model", {})
      +    if not isinstance(model_cfg, dict):
      +        model_cfg = {}
      +    model_cfg["provider"] = provider
      +    model_cfg["default"] = model
      +    cfg["model"] = model_cfg
      +    save_config(cfg)
      +
      +
      +class TestClearStaleOpenaiBaseUrl:
      +    """_clear_stale_openai_base_url() removes OPENAI_BASE_URL when provider is not custom."""
      +
      +    def test_clears_when_provider_is_named(self, monkeypatch):
      +        """OPENAI_BASE_URL is cleared when config provider is a named provider."""
      +        from hermes_cli.main import _clear_stale_openai_base_url
      +
      +        _write_provider("openrouter")
      +        save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
      +
      +        _clear_stale_openai_base_url()
      +
      +        result = get_env_value("OPENAI_BASE_URL")
      +        assert not result, f"Expected OPENAI_BASE_URL to be cleared, got: {result!r}"
      +
      +    def test_preserves_when_provider_is_custom(self, monkeypatch):
      +        """OPENAI_BASE_URL is NOT cleared when config provider is 'custom'."""
      +        from hermes_cli.main import _clear_stale_openai_base_url
      +
      +        _write_provider("custom")
      +        save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
      +
      +        _clear_stale_openai_base_url()
      +
      +        result = get_env_value("OPENAI_BASE_URL")
      +        assert result == "http://localhost:11434/v1", \
      +            f"Expected OPENAI_BASE_URL to be preserved, got: {result!r}"
      +
      +    def test_noop_when_no_openai_base_url(self, monkeypatch):
      +        """No error when OPENAI_BASE_URL is not set."""
      +        from hermes_cli.main import _clear_stale_openai_base_url
      +
      +        _write_provider("openrouter")
      +        # Ensure it's not set
      +        save_env_value("OPENAI_BASE_URL", "")
      +        monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
      +
      +        # Should not raise
      +        _clear_stale_openai_base_url()
      +
      +    def test_noop_when_provider_empty(self, monkeypatch):
      +        """No cleanup when provider is not set in config."""
      +        from hermes_cli.main import _clear_stale_openai_base_url
      +
      +        cfg = load_config()
      +        cfg.pop("model", None)
      +        save_config(cfg)
      +        save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1")
      +
      +        _clear_stale_openai_base_url()
      +
      +        result = get_env_value("OPENAI_BASE_URL")
      +        assert result == "http://localhost:11434/v1", \
      +            "Should not clear when provider is not configured"
  • #99725 fix(compression): OpenRouter fallback survives credit-limited 402s by clamping to the affordable budget (salvage #41055)

    merged to main

    files: agent/auxiliary_client.py, tests/agent/test_aux_affordable_402_retry.py, tests/agent/test_auxiliary_openrouter_max_tokens.py

21 PRs closed without merging

From the close comments: duplicate 6 · done elsewhere 4 · superseded by another PR 4 · merged via another change 3 · closed without comment 2 · PR volume cleanup 1 · other 1

  • #2457 fix(compression): remove hardcoded gemini-3-flash-preview as default summary model · merged via another change → #2464
  • #7594 fix(auxiliary): harden fallback behavior for non-OpenRouter users · merged via another change → #7647
  • #10339 fix: compression auto-detect reads config base_url for custom providers · PR volume cleanup
  • #21384 fix(agent): route bare ollama provider through custom branch · done elsewhere → 6a159be, #21352
  • #25180 fix(auxiliary): prefer fallback_providers model for OpenRouter aux tasks · done elsewhere → #36759, #50954
Show 16 more
  • #25316 fix(auxiliary): preserve :free suffix in auxiliary fallback models · superseded by another PR → #25180
  • #36789 fix(agent): honor declared fallback chain for aux runtime overrides · other
  • #38716 fix(agent): propagate mid-session model switch to auxiliary client (#36759) · done elsewhere → #36759, #50954
  • #41055 fix(auxiliary): preserve max_tokens for OpenRouter to prevent free-tier 402 · merged via another change → 13c3958, #99725, #49785, #41035
  • #41063 fix(openrouter): include max_tokens + fix credential pool fallback (#41035) · done elsewhere → c56b39c, #53906, #23495, #41035
  • #44939 fix(auxiliary): use empty default for OpenRouter auxiliary model (#44894) · closed without comment
  • #45077 fix: When openrouter key hermes uses paid Auxiliary default models for silly things like session title generation (#44894) · duplicate → #44939, #44894, #45091
  • #45091 fix(provider): OpenRouter key uses paid Auxiliary default models for silly things like session title generation (#44894) · duplicate → #44939, #44894
  • #45121 fix(auxiliary): use free default models for auxiliary tasks (#44894) · superseded by another PR → #44939, #44894
  • #50147 fix(auxiliary): preserve explicit caps for compatible routes · superseded by another PR → #50132
  • #50158 fix(agent): pass max_tokens unconditionally in _build_call_kwargs · duplicate → #50139, #50132, #50147
  • #56934 fix(auxiliary): preserve max_tokens for OpenRouter to prevent 402 · superseded by another PR → #41055
  • #56960 fix(auxiliary): preserve OpenRouter max_tokens for title generation · duplicate → #41055, #56934, #56957
  • #57382 fix(auxiliary): cap OpenRouter max tokens · duplicate → #41055, #56901
  • #58053 fix(auxiliary): preserve OpenRouter max tokens · duplicate → #41055
  • #106319 feat(cli): add profile-scoped free-only OpenRouter choices · closed without comment
Closed issues · 12
  • Closed#879[Feature]: Local model routing for auxiliary tasks (Ollama/custom endpoint support)
  • Closed#2453[Bug]: Agent uses gemini 3 flash preview from openrouter for ( compression / auxiliary _client and web_tools)
  • Closed#4171[Bug] Provider auto-detection ignores config.yaml — stale API keys override explicit custom endpoint
  • Closed#7559[Bug] Auxiliary model silently falls back to Gemini Flash on OpenRouter even when user configured local-only
  • Closed#10314[Bug]: Context compression falls back to OpenRouter default model instead of main provider when summary_provider=auto
  • Closed#21352[Bug]: provider: ollama not recognized by auxiliary_client.py — silently falls back to OpenRouter
  • Closed#24029Auxiliary tasks silently fall back to paid OpenRouter models, bypassing user's free-only configuration
  • Closed#24039Auxiliary fallback chain should reuse fallback_providers, not maintain a separate hardcoded list
  • Closed#36759Auxiliary fallback ignores session-level model switch and routes to paid model via unselected credential pool
  • Closed#41035aux: _build_call_kwargs strips max_tokens for OpenRouter — free tier gets HTTP 402, falls back to text-only model
  • Closed#44894When openrouter key hermes uses paid Auxiliary default models for silly things like session title generation
  • Closed#56901Auxiliary title generation falls back to OpenRouter with inflated max_tokens (65536) when user's main provider is a custom endpoint
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.04: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#2464, #7647, #99725
Closed issues
12
Attempts
21 PRs closed without merging
Fix link checked
31 Aug 2026

For your agent

The same facts, typed.