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
- 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.
Next step: read #2464, then check whether your report is one of the 3 still open.
Read #2464Pick up a Lab taskMultiple 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
Fixes on record
-
#2464 fix(compression): remove hardcoded gemini-3-flash-preview as default summary model
files:
cli.py,hermes_cli/config.pyShow 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)
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"
-
-
#99725 fix(compression): OpenRouter fallback survives credit-limited 402s by clamping to the affordable budget (salvage #41055)
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.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