hermes - 💡(How to fix) Fix /model picker merges custom_providers with same base_url but different key_env/api_mode, routes through wrong credentials

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…

Error Message

  • Silent misrouting — no error, just wrong credentials and protocol

Root Cause

# model_switch.py section 4
api_key = (entry.get("api_key") or "").strip()
group_key = (api_url, api_key)  # both entries have api_key="" → same group

When key_env is used instead of inline api_key, the grouping key is identical for all same-host providers.

Fix Action

Workaround

Use a unique base_url per provider (not always possible with shared gateways), or select models with the explicit --provider flag bypassing the picker:

/model claude-opus-4-8 --provider custom:claude

Code Example

custom_providers:
     - name: gpt
       base_url: https://gateway.example.com
       key_env: GPT_KEY
       api_mode: codex_responses
       model: gpt-5.5
     - name: claude
       base_url: https://gateway.example.com
       key_env: CLAUDE_KEY
       api_mode: anthropic_messages
       model: claude-opus-4-8

---

# model_switch.py section 4
api_key = (entry.get("api_key") or "").strip()
group_key = (api_url, api_key)  # both entries have api_key="" → same group

---

api_key = (entry.get("api_key") or "").strip()
key_env = (entry.get("key_env") or "").strip()
api_mode = (entry.get("api_mode") or "").strip()
credential_identity = api_key if api_key else (f"env:{key_env}" if key_env else "")
group_key = (api_url, credential_identity, api_mode)

---

def test_list_authenticated_providers_same_url_different_key_env_and_api_mode_stay_separate(monkeypatch):
    """Same host + different key_env/api_mode → separate picker rows."""
    providers = list_authenticated_providers(
        current_provider="custom:gpt",
        current_base_url="https://gateway.example.com",
        custom_providers=[
            {"name": "gpt", "base_url": "https://gateway.example.com",
             "key_env": "GPT_KEY", "api_mode": "codex_responses", "model": "gpt-5.5"},
            {"name": "claude", "base_url": "https://gateway.example.com",
             "key_env": "CLAUDE_KEY", "api_mode": "anthropic_messages", "model": "claude-opus-4-8"},
        ],
    )
    custom = [p for p in providers if p.get("is_user_defined")]
    by_slug = {p["slug"]: p for p in custom}
    assert "custom:gpt" in by_slug
    assert "custom:claude" in by_slug
    assert by_slug["custom:gpt"]["models"] == ["gpt-5.5"]
    assert by_slug["custom:claude"]["models"] == ["claude-opus-4-8"]

---

/model claude-opus-4-8 --provider custom:claude
RAW_BUFFERClick to expand / collapse

Bug Description

The /model picker groups custom_providers entries by (api_url, api_key) tuple (model_switch.py section 4). When multiple providers share the same base_url but use key_env instead of inline api_key, the api_key field is empty string for all entries, producing the same group_key. This merges distinct providers into one picker row, and selecting a model from the merged row routes through the first provider's credentials and api_mode.

Impact

  • Two providers on the same gateway (e.g. GPT Responses + Anthropic Messages) with different key_env and api_mode are collapsed into one row
  • Selecting a Claude model from the merged row sends requests through the GPT provider's key and codex_responses protocol
  • The intended provider's key is never used; no backend logs appear for that provider
  • Silent misrouting — no error, just wrong credentials and protocol

Reproduction

  1. Configure two custom_providers with the same base_url, different key_env, and different api_mode:
    custom_providers:
      - name: gpt
        base_url: https://gateway.example.com
        key_env: GPT_KEY
        api_mode: codex_responses
        model: gpt-5.5
      - name: claude
        base_url: https://gateway.example.com
        key_env: CLAUDE_KEY
        api_mode: anthropic_messages
        model: claude-opus-4-8
  2. Run /model — only one row appears (e.g. gpt with both gpt-5.5 and claude-opus-4-8)
  3. Select claude-opus-4-8 — request goes through GPT_KEY + codex_responses instead of CLAUDE_KEY + anthropic_messages

Root Cause

# model_switch.py section 4
api_key = (entry.get("api_key") or "").strip()
group_key = (api_url, api_key)  # both entries have api_key="" → same group

When key_env is used instead of inline api_key, the grouping key is identical for all same-host providers.

Suggested Fix

Expand the group key to include credential identity and wire protocol:

api_key = (entry.get("api_key") or "").strip()
key_env = (entry.get("key_env") or "").strip()
api_mode = (entry.get("api_mode") or "").strip()
credential_identity = api_key if api_key else (f"env:{key_env}" if key_env else "")
group_key = (api_url, credential_identity, api_mode)

Also tighten the is_current assignment to exact slug match so same-host providers don't all get marked as current.

Related Issues

  • #30653 — picker ignores key_env for model discovery (different symptom; PR #29174 adds key_env reading but does not fix grouping)
  • #14141 — runtime credential pool resolution by base_url (different layer: runtime_provider.py)

Test Case

A regression test verifying the fix:

def test_list_authenticated_providers_same_url_different_key_env_and_api_mode_stay_separate(monkeypatch):
    """Same host + different key_env/api_mode → separate picker rows."""
    providers = list_authenticated_providers(
        current_provider="custom:gpt",
        current_base_url="https://gateway.example.com",
        custom_providers=[
            {"name": "gpt", "base_url": "https://gateway.example.com",
             "key_env": "GPT_KEY", "api_mode": "codex_responses", "model": "gpt-5.5"},
            {"name": "claude", "base_url": "https://gateway.example.com",
             "key_env": "CLAUDE_KEY", "api_mode": "anthropic_messages", "model": "claude-opus-4-8"},
        ],
    )
    custom = [p for p in providers if p.get("is_user_defined")]
    by_slug = {p["slug"]: p for p in custom}
    assert "custom:gpt" in by_slug
    assert "custom:claude" in by_slug
    assert by_slug["custom:gpt"]["models"] == ["gpt-5.5"]
    assert by_slug["custom:claude"]["models"] == ["claude-opus-4-8"]

Workaround

Use a unique base_url per provider (not always possible with shared gateways), or select models with the explicit --provider flag bypassing the picker:

/model claude-opus-4-8 --provider custom:claude

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

hermes - 💡(How to fix) Fix /model picker merges custom_providers with same base_url but different key_env/api_mode, routes through wrong credentials