Auxiliary client routing, caching, and config fixes
NousResearch/hermes-agent · cluster 5803 · agent
Data as of
Nothing open on GitHub. Fix merged in #7647. Check it covers your case.
The short answer
- Known?
- Yes. 9 related items grouped here, 4 issues already closed.
- Fix event
- A fix was merged to main in PR #7647. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #7647, #8992. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items address bugs and improvements in auxiliary client routing logic, including cache hit behavior bypassing compatibility checks, removal of legacy compression config fallbacks, and fixes to model slug handling across provider types. Several entries concern how auxiliary tasks are routed when the provider is set to 'auto' or non-aggregator configurations.
Open issues0
Open PRs0
Fixes on record
-
#7647 fix(auxiliary): consolidate auxiliary client UX hardening (#7605)
files:
agent/auxiliary_client.py,hermes_cli/main.py,tests/agent/test_auxiliary_client.py,tests/hermes_cli/test_clear_stale_base_url.pyShow 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"
-
-
#8992 fix: remove legacy compression.summary_* config and env var fallbacks
files:
agent/auxiliary_client.py,cli-config.yaml.example,cli.py,hermes_cli/config.py,optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py,run_agent.py,tests/agent/test_auxiliary_client.py,tests/agent/test_auxiliary_config_bridge.py,tests/agent/test_auxiliary_named_custom_providers.py,tests/cli/test_cli_save_config_value.py,tests/hermes_cli/test_tools_config.py,tests/tools/test_vision_tools.pyShow the diff
-
agent/auxiliary_client.py@@ -27,10 +27,6 @@ (e.g. ``auxiliary.vision.provider``, ``auxiliary.compression.model``). Default "auto" follows the chains above. -Legacy env var overrides (AUXILIARY_{TASK}_PROVIDER, AUXILIARY_{TASK}_MODEL, -AUXILIARY_{TASK}_BASE_URL, etc.) are still read as a backward-compat fallback -but config.yaml takes priority. New configuration should always use config.yaml. - Payment / credit exhaustion fallback: When a resolved provider returns HTTP 402 or a credit-related error, call_llm() automatically retries with the next available provider in the @@ -753,30 +749,6 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: # ── Provider resolution helpers ───────────────────────────────────────────── -def _get_auxiliary_provider(task: str = "") -> str: - """Read the provider override for a specific auxiliary task. - - Checks AUXILIARY_{TASK}_PROVIDER first (e.g. AUXILIARY_VISION_PROVIDER), - then CONTEXT_{TASK}_PROVIDER (for the compression section's summary_provider), - then falls back to "auto". Returns one of: "auto", "openrouter", "nous", "main". - """ - if task: - for prefix in ("AUXILIARY_", "CONTEXT_"): - val = os.getenv(f"{prefix}{task.upper()}_PROVIDER", "").strip().lower() - if val and val != "auto": - return val - return "auto" - - -def _get_auxiliary_env_override(task: str, suffix: str) -> Optional[str]: - """Read an auxiliary env override from AUXILIARY_* or CONTEXT_* prefixes.""" - if not task: - return None - for prefix in ("AUXILIARY_", "CONTEXT_"): - val = os.getenv(f"{prefix}{task.upper()}_{suffix}", "").strip() - if val: - return val - return None def _try_openrouter() -> Tuple[Optional[OpenAI], Optional[str]]: @@ -1591,8 +1563,8 @@ def get_text_auxiliary_client( task: Optional task name ("compression", "web_extract") to check for a task-specific provider override. - Callers may override the returned model with a per-task env var - (e.g. CONTEXT_COMPRESSION_MODEL, AUXILIARY_WEB_EXTRACT_MODEL). + Callers may override the returned model via config.yaml + (e.g. auxiliary.compression.model, auxiliary.web_extract.model). """ provider, model, base_url, api_key, api_mode = _resolve_task_provider_model(task or None) return resolve_provider_client( @@ -2011,9 +1983,8 @@ def _resolve_task_provider_model( Priority: 1. Explicit provider/model/base_url/api_key args (always win) - 2. Config file (auxiliary.{task}.* or compression.*) - 3. Env var overrides (backward-compat: AUXILIARY_{TASK}_*, CONTEXT_{TASK}_*) - 4. "auto" (full auto-detection chain) + 2. Config file (auxiliary.{task}.provider/model/base_url) + 3. "auto" (full auto-detection chain) 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 @@ -2044,22 +2015,8 @@ def _resolve_task_provider_model( 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 - # both None and "auto" as "not explicitly configured". - if task == "compression" and (not cfg_provider or cfg_provider == "auto"): - comp = config.get("compression", {}) if isinstance(config, dict) else {} - if isinstance(comp, dict): - cfg_provider = comp.get("summary_provider", "").strip() or None - cfg_model = cfg_model or comp.get("summary_model", "").strip() or None - _sbu = comp.get("summary_base_url") or "" - cfg_base_url = cfg_base_url or _sbu.strip() or None - - # Env vars are backward-compat fallback only — config.yaml is primary. - 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 cfg_model or env_model - resolved_api_mode = cfg_api_mode or env_api_mode + resolved_model = model or cfg_model + resolved_api_mode = cfg_api_mode if base_url: return "custom", resolved_model, base_url, api_key, resolved_api_mode @@ -2073,17 +2030,6 @@ def _resolve_task_provider_model( if cfg_provider and cfg_provider != "auto": return cfg_provider, resolved_model, None, None, resolved_api_mode - # Env vars are backward-compat fallback for users who haven't - # migrated to config.yaml yet. - 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, resolved_api_mode - - env_provider = _get_auxiliary_provider(task) - if env_provider != "auto": - return env_provider, resolved_model, None, None, resolved_api_mode - return "auto", resolved_model, None, None, resolved_api_mode return "auto", resolved_model, None, None, resolved_api_mode -
cli-config.yaml.example@@ -309,15 +309,8 @@ compression: # compression of older turns. protect_last_n: 20 - # Model to use for generating summaries (fast/cheap recommended) - # This model compresses the middle turns into a concise summary. - # IMPORTANT: it receives the full middle section of the conversation, so it - # MUST support a context length at least as large as your main model's. - summary_model: "google/gemini-3-flash-preview" - - # Provider for the summary model (default: "auto") - # Options: "auto", "openrouter", "nous", "main" - # summary_provider: "auto" + # To pin a specific model/provider for compression summaries, use the + # auxiliary section below (auxiliary.compression.provider / model). # ============================================================================= # Auxiliary Models (Advanced — Experimental) -
cli.py@@ -237,7 +237,6 @@ 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": "", # Model for summaries (empty = use main model) }, "smart_model_routing": { "enabled": False, -
hermes_cli/config.py@@ -414,9 +414,7 @@ def _ensure_hermes_home_managed(home: Path): "threshold": 0.50, # compress when context usage exceeds this ratio "target_ratio": 0.20, # fraction of threshold to preserve as recent tail "protect_last_n": 20, # minimum recent messages to keep uncompressed - "summary_model": "", # empty = use main configured model - "summary_provider": "auto", - "summary_base_url": None, + }, "smart_model_routing": { "enabled": False, @@ -702,7 +700,7 @@ def _ensure_hermes_home_managed(home: Path): }, # Config schema version - bump this when adding new required fields - "_config_version": 16, + "_config_version": 17, } # ============================================================================= @@ -1975,6 +1973,43 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A print(f" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}") results["config_added"].append("display.platforms (migrated from tool_progress_overrides)") + # ── Version 16 → 17: remove legacy compression.summary_* keys ── + if current_ver < 17: + config = read_raw_config() + comp = config.get("compression", {}) + if isinstance(comp, dict): + s_model = comp.pop("summary_model", None) + s_provider = comp.pop("summary_provider", None) + s_base_url = comp.pop("summary_base_url", None) + migrated_keys = [] + # Migrate non-empty, non-default values to auxiliary.compression + if s_model and str(s_model).strip(): + aux = config.setdefault("auxiliary", {}) + aux_comp = aux.setdefault("compression", {}) + if not aux_comp.get("model"): + aux_comp["model"] = str(s_model).strip() + migrated_keys.append(f"model={s_model}") + if s_provider and str(s_provider).strip() not in ("", "auto"): + aux = config.setdefault("auxiliary", {}) + aux_comp = aux.setdefault("compression", {}) + if not aux_comp.get("provider") or aux_comp.get("provider") == "auto": + aux_comp["provider"] = str(s_provider).strip() + migrated_keys.append(f"provider={s_provider}") + if s_base_url and str(s_base_url).strip(): + aux = config.setdefault("auxiliary", {}) + aux_comp = aux.setdefault("compression", {}) + if not aux_comp.get("base_url"): + aux_comp["base_url"] = str(s_base_url).strip() + migrated_keys.append(f"base_url={s_base_url}") + if migrated_keys or s_model is not None or s_provider is not None or s_base_url is not None: + config["compression"] = comp + save_config(config) + if not quiet: + if migrated_keys: + print(f" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}") + else: + print(" ✓ Removed unused compression.summary_* keys") + if current_ver < latest_ver and not quiet: print(f"Config version: {current_ver} → {latest_ver}") @@ -2790,10 +2825,11 @@ def show_config(): print(f" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%") print(f" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved") print(f" Protect last: {compression.get('protect_last_n', 20)} messages") - _sm = compression.get('summary_model', '') or '(main model)' + _aux_comp = config.get('auxiliary', {}).get('compression', {}) + _sm = _aux_comp.get('model', '') or '(auto)' print(f" Model: {_sm}") - comp_provider = compression.get('summary_provider', 'auto') - if comp_provider != 'auto': + comp_provider = _aux_comp.get('provider', 'auto') + if comp_provider and comp_provider != 'auto': print(f" Provider: {comp_provider}") # Auxiliary models -
optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py@@ -1995,7 +1995,9 @@ def migrate_agent_config(self, config: Optional[Dict[str, Any]] = None) -> None: if compaction.get("timeout"): pass # No direct mapping if compaction.get("model"): - compression["summary_model"] = compaction["model"] + aux = hermes_cfg.setdefault("auxiliary", {}) + aux_comp = aux.setdefault("compression", {}) + aux_comp["model"] = compaction["model"] hermes_cfg["compression"] = compression changes = True -
run_agent.py@@ -1212,7 +1212,6 @@ def __init__( _compression_cfg = {} compression_threshold = float(_compression_cfg.get("threshold", 0.50)) compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in ("true", "1", "yes") - compression_summary_model = _compression_cfg.get("summary_model") or None compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20)) compression_protect_last = int(_compression_cfg.get("protect_last_n", 20)) @@ -1301,7 +1300,7 @@ def __init__( protect_first_n=3, protect_last_n=compression_protect_last, summary_target_ratio=compression_target_ratio, - summary_model_override=compression_summary_model, + summary_model_override=None, quiet_mode=self.quiet_mode, base_url=self.base_url, api_key=[redacted:secret-assignment] "api_key", ""), -
tests/agent/test_auxiliary_client.py@@ -17,7 +17,6 @@ call_llm, async_call_llm, _read_codex_access_token, - _get_auxiliary_provider, _get_provider_chain, _is_payment_error, _try_payment_fallback, @@ -32,12 +31,6 @@ def _clean_env(monkeypatch): "OPENROUTER_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_KEY", "OPENAI_MODEL", "LLM_MODEL", "NOUS_INFERENCE_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", - # Per-task provider/model/direct-endpoint overrides - "AUXILIARY_VISION_PROVIDER", "AUXILIARY_VISION_MODEL", - "AUXILIARY_VISION_BASE_URL", "AUXILIARY_VISION_API_KEY", - "AUXILIARY_WEB_EXTRACT_PROVIDER", "AUXILIARY_WEB_EXTRACT_MODEL", - "AUXILIARY_WEB_EXTRACT_BASE_URL", "AUXILIARY_WEB_EXTRACT_API_KEY", - "CONTEXT_COMPRESSION_PROVIDER", "CONTEXT_COMPRESSION_MODEL", ): monkeypatch.delenv(key, raising=False) @@ -568,29 +561,6 @@ def test_custom_endpoint_over_codex(self, monkeypatch, codex_auth_dir): call_kwargs = mock_openai.call_args assert call_kwargs.kwargs["base_url"] == "http://localhost:1234/v1" - def test_task_direct_endpoint_override(self, monkeypatch): - monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") - monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_BASE_URL", "http://localhost:2345/v1") - monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_API_KEY", "task-key") - monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_MODEL", "task-model") - with patch("agent.auxiliary_client.OpenAI") as mock_openai: - client, model = get_text_auxiliary_client("web_extract") - assert model == "task-model" - assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:2345/v1" - assert mock_openai.call_args.kwargs["api_key"] == "task-key" - - def test_task_direct_endpoint_without_openai_key_uses_placeholder(self, monkeypatch): - """Local endpoints without an API key should use 'no-key-required' placeholder.""" - monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") - monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_BASE_URL", "http://localhost:2345/v1") - monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_MODEL", "task-model") - with patch("agent.auxiliary_client.OpenAI") as mock_openai: - client, model = get_text_auxiliary_client("web_extract") - assert client is not None - assert model == "task-model" - assert mock_openai.call_args.kwargs["api_key"] == "no-key-required" - assert mock_openai.call_args.kwargs["base_url"] == "http://localhost:2345/v1" - def test_custom_endpoint_uses_config_saved_base_url(self, monkeypatch): config = { "model": { @@ -879,73 +849,9 @@ def test_vision_config_google_provider_uses_gemini_credentials(self, monkeypatch -class TestGetAuxiliaryProvider: - """Tests for _get_auxiliary_provider env var resolution.""" - - def test_no_task_returns_auto(self): - assert _get_auxiliary_provider() == "auto" - assert _get_auxiliary_provider("") == "auto" - - def test_auxiliary_prefix_takes_priority(self, monkeypatch): - monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "openrouter") - assert _get_auxiliary_provider("vision") == "openrouter" - - def test_context_prefix_fallback(self, monkeypatch): - monkeypatch.setenv("CONTEXT_COMPRESSION_PROVIDER", "nous") - assert _get_auxiliary_provider("compression") == "nous" - - def test_auxiliary_prefix_over_context_prefix(self, monkeypatch): - monkeypatch.setenv("AUXILIARY_COMPRESSION_PROVIDER", "openrouter") - monkeypatch.setenv("CONTEXT_COMPRESSION_PROVIDER", "nous") - assert _get_auxiliary_provider("compression") == "openrouter" - - def test_auto_value_treated_as_auto(self, monkeypatch): - monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "auto") - assert _get_auxiliary_provider("vision") == "auto" - - def test_whitespace_stripped(self, monkeypatch): - monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", " openrouter ") - assert _get_auxiliary_provider("vision") == "openrouter" - - def test_case_insensitive(self, monkeypatch): - monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "OpenRouter") - assert _get_auxiliary_provider("vision") == "openrouter" - - def test_main_provider(self, monkeypatch): - monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_PROVIDER", "main") - assert _get_auxiliary_provider("web_extract") == "main" - - class TestTaskSpecificOverrides: """Integration tests for per-task provider routing via get_text_auxiliary_client(task=...).""" - def test_text_with_vision_provider_override(self, monkeypatch): - """AUXILIARY_VISION_PROVIDER should not affect text tasks.""" - monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "nous") - monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") - with patch("agent.auxiliary_client.OpenAI"): - client, model = get_text_auxiliary_client() # no task → auto - assert model == "google/gemini-3-flash-preview" # OpenRouter, not Nous - - def test_compression_task_reads_context_prefix(self, monkeypatch): - """Compression task should check CONTEXT_COMPRESSION_PROVIDER env var.""" - monkeypatch.setenv("CONTEXT_COMPRESSION_PROVIDER", "nous") - monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") # would win in auto - with patch("agent.auxiliary_client._read_nous_auth") as mock_nous, \ - patch("agent.auxiliary_client.OpenAI"): - mock_nous.return_value = {"access_token": "***"} - client, model = get_text_auxiliary_client("compression") - # Config-first: model comes from config.yaml summary_model default, - # but provider is forced to Nous via env var - assert client is not None - - def test_web_extract_task_override(self, monkeypatch): - monkeypatch.setenv("AUXILIARY_WEB_EXTRACT_PROVIDER", "openrouter") - monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") - with patch("agent.auxiliary_client.OpenAI"): - client, model = get_text_auxiliary_client("web_extract") - assert model == "google/gemini-3-flash-preview" - def test_task_direct_endpoint_from_config(self, monkeypatch, tmp_path): hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) @@ -979,8 +885,6 @@ def test_resolve_auto_prefers_live_main_runtime_over_persisted_config(self, monk """model: default: glm-5.1 provider: opencode-go -compression: - summary_provider: auto """ ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) @@ -1039,25 +943,6 @@ def test_explicit_compression_pin_still_wins_over_live_main_runtime(self, monkey "model": "gpt-5.4", } - def test_compression_summary_base_url_from_config(self, monkeypatch, tmp_path): - """compression.summary_base_url should produce a custom-endpoint client.""" - hermes_home = tmp_path / "hermes" - hermes_home.mkdir(parents=True, exist_ok=True) - (hermes_home / "config.yaml").write_text( - """compression: - summary_provider: custom - summary_model: glm-4.7 - summary_base_url: https://api.z.ai/api/coding/paas/v4 -""" - ) - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - # Custom endpoints need an API key to build the client - monkeypatch.setenv("OPENAI_API_KEY", "test-key") - with patch("agent.auxiliary_client.OpenAI") as mock_openai: - client, model = get_text_auxiliary_client("compression") - assert model == "glm-4.7" - assert mock_openai.call_args.kwargs["base_url"] == "https://api.z.ai/api/coding/paas/v4" - class TestAuxiliaryMaxTokensParam: def test_codex_fallback_uses_max_tokens(self, monkeypatch): -
tests/agent/test_auxiliary_config_bridge.py@@ -273,18 +273,6 @@ def test_web_extract_task_structure(self): assert web["provider"] == "auto" assert web["model"] == "" - def test_compression_provider_default(self): - from hermes_cli.config import DEFAULT_CONFIG - compression = DEFAULT_CONFIG["compression"] - assert "summary_provider" in compression - assert compression["summary_provider"] == "auto" - - def test_compression_base_url_default(self): - from hermes_cli.config import DEFAULT_CONFIG - compression = DEFAULT_CONFIG["compression"] - assert "summary_base_url" in compression - assert compression["summary_base_url"] is None - # ── CLI defaults parity ───────────────────────────────────────────────────── -
tests/agent/test_auxiliary_named_custom_providers.py@@ -12,17 +12,6 @@ def _isolate(tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - for env_var in ( - "AUXILIARY_VISION_PROVIDER", - "AUXILIARY_VISION_MODEL", - "AUXILIARY_VISION_BASE_URL", - "AUXILIARY_VISION_API_KEY", - "CONTEXT_VISION_PROVIDER", - "CONTEXT_VISION_MODEL", - "CONTEXT_VISION_BASE_URL", - "CONTEXT_VISION_API_KEY", - ): - monkeypatch.delenv(env_var, raising=False) # Write a minimal config so load_config doesn't fail (hermes_home / "config.yaml").write_text("model:\n default: test-model\n") -
tests/cli/test_cli_save_config_value.py@@ -51,10 +51,10 @@ def test_preserves_existing_keys(self, config_env): def test_creates_nested_keys(self, config_env): """Dot-separated paths create intermediate dicts as needed.""" from cli import save_config_value - save_config_value("compression.summary_model", "google/gemini-3-flash-preview") + save_config_value("auxiliary.compression.model", "google/gemini-3-flash-preview") result = yaml.safe_load(config_env.read_text()) - assert result["compression"]["summary_model"] == "google/gemini-3-flash-preview" + assert result["auxiliary"]["compression"]["model"] == "google/gemini-3-flash-preview" def test_overwrites_existing_value(self, config_env): """Updating an existing key replaces the value.""" -
tests/hermes_cli/test_tools_config.py@@ -119,8 +119,7 @@ def test_toolset_has_keys_for_vision_accepts_codex_auth(tmp_path, monkeypatch): monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) monkeypatch.delenv("OPENAI_BASE_URL", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) - monkeypatch.delenv("AUXILIARY_VISION_PROVIDER", raising=False) - monkeypatch.delenv("CONTEXT_VISION_PROVIDER", raising=False) + monkeypatch.setattr( "agent.auxiliary_client.resolve_vision_provider_client", lambda: ("openai-codex", object(), "gpt-4.1"), -
tests/tools/test_vision_tools.py@@ -463,8 +463,6 @@ def test_check_requirements_accepts_codex_auth(self, monkeypatch, tmp_path): monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) monkeypatch.delenv("OPENAI_BASE_URL", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) - monkeypatch.delenv("AUXILIARY_VISION_PROVIDER", raising=False) - monkeypatch.delenv("CONTEXT_VISION_PROVIDER", raising=False) assert check_vision_requirements() is True
-
3 PRs closed without merging
From the close comments: done elsewhere 2 · merged via another change 1
-
#5804 fix: drop incompatible model slugs on auxiliary client cache hit · merged via another change →
#7647 -
#8955 Bugfix: ignore legacy compression model for auto providers · done elsewhere →
#8992,#8923 -
#39094 fix(auxiliary): infer provider from model slug when provider is auto · done elsewhere →
4f5e8b2,#39047
Closed issues · 4
- Closed#5803Auxiliary client cache bypasses model slug compatibility check, sends OpenRouter-format models to Codex API
- Closed#5809Auxiliary client cache hit bypasses model slug compatibility check
- Closed#8923Bug: Default compression config silently fails for non-OpenRouter setups
- Closed#39047Auxiliary compression routes provider-qualified Gemini model to Codex backend
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.19: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means 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