hermes - ✅(Solved) Fix bug: bare 'custom' provider falls through to OpenRouter — no base_url resolution [2 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
NousResearch/hermes-agent#14676Fetched 2026-04-24 06:15:24
View on GitHub
Comments
1
Participants
2
Timeline
8
Reactions
0
Timeline (top)
labeled ×4cross-referenced ×3commented ×1

Root Cause

In runtime_provider.py, _get_named_custom_provider() (line 289) explicitly returns None for bare "custom":

def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, Any]]:
    requested_norm = _normalize_custom_provider_name(requested_provider or "")
    if not requested_norm or requested_norm == "custom":
        return None  # <-- bare 'custom' always returns None

This causes _resolve_named_custom_runtime() to return None, and the resolution chain falls all the way through to _resolve_openrouter_runtime().

The _get_custom_base_url() function in models.py reads model.base_url from config.yaml, but that's the default model's URL (e.g. Z.AI or OpenRouter), not a local server. There is no CUSTOM_BASE_URL env var check in the runtime provider resolution path — it only exists in models.py for the model list display.

Named custom providers (e.g. custom:local) with entries under providers: in config.yaml work correctly because they match in _get_named_custom_provider().

Fix Action

Workaround

Manually add named entries to ~/.hermes/config.yaml:

providers:
  local:
    base_url: http://localhost:8082/v1
    api_key: no-key-required
    default_model: your-model.gguf

Then use custom:local when hotswapping in-session.

PR fix notes

PR #14718: fix(agent): map bare "custom" provider to named providers: key via base_url match

Description (problem / solution / changelog)

What does this PR do?

When auxiliary models (vision, web_extract) use _read_main_provider(), they get the literal string "custom" for model.provider if the user configured a custom endpoint. But _get_named_custom_provider() and _resolve_vision_provider() expect a provider key name that exists in the providers: dict — they look up credentials, defaults, and client adapters by that key. This mismatch caused auxiliary tasks to fail with None provider resolution.

The fix enhances _read_main_provider() in agent/auxiliary_client.py: when the bare "custom" provider is detected, it searches the providers: dictionary for an entry whose api, url, or base_url matches the model base_url. If found, it returns the actual key name instead of "custom". This bridges the gap between config-time naming (what users put in providers: my-local) and runtime lookup (what auxiliary resolution code expects).

Related Issue

Fixes #14676

Type of Change

  • Bug fix (non-breaking change that fixes an issue)

Changes Made

  • agent/auxiliary_client.py: Added base_url matching logic in _read_main_provider() that iterates providers: entries and returns the first key whose api/url/base_url matches the model configured base_url (with trailing-slash normalization). Falls through to bare "custom" if no match.
  • tests/agent/test_auxiliary_named_custom_providers.py: Added 6 test cases covering: bare custom with matching providers entry, bare custom with no match, providers entries using "base_url" key, non-custom passthrough, absent providers dict fallback, and trailing slash normalization.

How to Test

  1. Run the new tests: pytest tests/agent/test_auxiliary_named_custom_providers.py -o addopts= -v
  2. Run the full auxiliary test suite: pytest tests/agent/test_auxiliary*.py -o addopts= -q (all 142 pass)
  3. For an integration test, configure a named custom provider in config.yaml and verify auxiliary tasks (vision model, web extract) resolve it correctly via the _read_main_provider() mapping.

Checklist

Code

  • Read Contributing Guide
  • Commit messages follow Conventional Commits (fix(scope):)
  • Searched for existing PRs — this is not a duplicate
  • PR contains only changes related to this fix (no unrelated commits)
  • All tests pass: pytest tests/ -q
  • Added tests for my changes
  • Tested on: Ubuntu 24.04

Documentation & Housekeeping

  • N/A — docstring updated in _read_main_provider()
  • N/A — no config key changes
  • N/A — no architecture or workflow changes
  • N/A — no cross-platform concerns
  • N/A — no tool schema changes

Screenshots / Logs

Test output:

tests/agent/test_auxiliary_named_custom_providers.py ... 25 passed in X.XXs
All 142 auxiliary tests pass.

Changed files

  • agent/auxiliary_client.py (modified, +17/-2)
  • tests/agent/test_auxiliary_named_custom_providers.py (modified, +90/-0)

PR #14719: fix(runtime): resolve bare custom provider to loopback or CUSTOM_BASE_URL (#14676)

Description (problem / solution / changelog)

Summary

Fixes incorrect routing when the user explicitly selects the Custom provider (e.g. via /model) while model.provider in config.yaml still reflects a previous provider (e.g. openrouter). In that case, model.base_url was ignored unless provider: custom, so resolution fell through to OpenRouter with a local model id (see #14676).

Changes

  • Trust model.base_url for bare custom when either:
    • model.provider is custom, or
    • the configured base_url host is loopback (localhost, 127.0.0.1, ::1, 0.0.0.0).
  • Consult CUSTOM_BASE_URL before OpenRouter default URLs so users can pin a local endpoint without a named custom:* entry.
  • Reject non-loopback model.base_url when provider is not custom, so a stale cloud URL cannot hijack a Custom session.

Tests

  • tests/hermes_cli/test_runtime_provider_resolution.py: three regressions for loopback YAML + non-custom provider, CUSTOM_BASE_URL override, and non-loopback URL not trusted.

Fixes #14676

Changed files

  • acp_adapter/server.py (modified, +13/-19)
  • hermes_cli/runtime_provider.py (modified, +28/-1)
  • tests/acp/test_approval_isolation.py (modified, +41/-4)
  • tests/hermes_cli/test_runtime_provider_resolution.py (modified, +66/-0)
  • tools/approval.py (modified, +39/-2)
  • tools/cronjob_tools.py (modified, +3/-1)
  • tools/terminal_tool.py (modified, +2/-1)

Code Example

def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, Any]]:
    requested_norm = _normalize_custom_provider_name(requested_provider or "")
    if not requested_norm or requested_norm == "custom":
        return None  # <-- bare 'custom' always returns None

---

providers:
  local:
    base_url: http://localhost:8082/v1
    api_key: no-key-required
    default_model: your-model.gguf
RAW_BUFFERClick to expand / collapse

Problem

When a user selects the Custom provider (for llama.cpp, LM Studio, Ollama, vLLM, etc.) via hermes setup or the /model picker, the runtime resolves to OpenRouter instead of the local server. No request ever reaches the local model — it silently hits OpenRouter with the local model name (e.g. Carnice-9b-Q4_K_M.gguf), which of course fails.

Root Cause

In runtime_provider.py, _get_named_custom_provider() (line 289) explicitly returns None for bare "custom":

def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, Any]]:
    requested_norm = _normalize_custom_provider_name(requested_provider or "")
    if not requested_norm or requested_norm == "custom":
        return None  # <-- bare 'custom' always returns None

This causes _resolve_named_custom_runtime() to return None, and the resolution chain falls all the way through to _resolve_openrouter_runtime().

The _get_custom_base_url() function in models.py reads model.base_url from config.yaml, but that's the default model's URL (e.g. Z.AI or OpenRouter), not a local server. There is no CUSTOM_BASE_URL env var check in the runtime provider resolution path — it only exists in models.py for the model list display.

Named custom providers (e.g. custom:local) with entries under providers: in config.yaml work correctly because they match in _get_named_custom_provider().

Steps to Reproduce

  1. Run a local server on localhost:8082 (llama.cpp, LM Studio, etc.)
  2. In a Hermes session, use /model to switch to the Custom provider
  3. Select a model from the local server
  4. Send a message — it routes to OpenRouter instead of localhost

Expected Behavior

Bare custom provider should resolve to a local endpoint. Options:

  • Check CUSTOM_BASE_URL env var during runtime provider resolution (not just for model listing)
  • Or: hermes setup should create a named entry under providers: in config.yaml when the user configures a custom endpoint (e.g. providers.local.base_url)
  • Or: fall back to model.base_url only when it looks like a local URL (localhost/127.0.0.1)

Workaround

Manually add named entries to ~/.hermes/config.yaml:

providers:
  local:
    base_url: http://localhost:8082/v1
    api_key: no-key-required
    default_model: your-model.gguf

Then use custom:local when hotswapping in-session.

Environment

  • Hermes Agent v0.10.0 (2026.4.16)
  • Python 3.11.14
  • Local backend: llama.cpp server on localhost:8082

extent analysis

TL;DR

To fix the issue, modify the _get_named_custom_provider() function to check the CUSTOM_BASE_URL environment variable when the requested provider is "custom".

Guidance

  • Modify the _get_named_custom_provider() function to return a dictionary with the local server's base URL when the requested provider is "custom" and the CUSTOM_BASE_URL environment variable is set.
  • Alternatively, update the hermes setup process to create a named entry under providers: in config.yaml when a custom endpoint is configured.
  • Verify the fix by running the steps to reproduce and checking that the request is routed to the local server instead of OpenRouter.
  • Consider adding a fallback to model.base_url only when it appears to be a local URL (e.g., localhost or 127.0.0.1).

Example

def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, Any]]:
    requested_norm = _normalize_custom_provider_name(requested_provider or "")
    if not requested_norm or requested_norm == "custom":
        custom_base_url = os.environ.get('CUSTOM_BASE_URL')
        if custom_base_url:
            return {'base_url': custom_base_url}
        # else, return None or a default value

Notes

The provided workaround of manually adding named entries to ~/.hermes/config.yaml can be used as a temporary solution until the root cause is fixed.

Recommendation

Apply the workaround by manually adding named entries to ~/.hermes/config.yaml until a permanent fix is implemented, as it provides a reliable way to route requests to the local server.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING