Skip to content

fix(cli): resolve aggregator models via custom_providers and live /v1/models when models.dev is stale - #33716

Open
OmarB97 wants to merge 1 commit into
NousResearch:mainfrom
OmarB97:fix/model-switch-aggregator-clean
Open

fix(cli): resolve aggregator models via custom_providers and live /v1/models when models.dev is stale#33716
OmarB97 wants to merge 1 commit into
NousResearch:mainfrom
OmarB97:fix/model-switch-aggregator-clean

Conversation

@OmarB97

@OmarB97 OmarB97 commented May 28, 2026

Copy link
Copy Markdown
Contributor

Why

When a user types /model mimo-v2.5 on opencode-zen (or any aggregator), the model switch pipeline falls through to detect_provider_for_model() which finds the model in xiaomi's static catalog and switches providers — requiring an API key the user doesn't have. The user gets a 401 auth error instead of staying on their configured aggregator.

Root cause chain:

  1. /model mimo-v2.5 triggers switch_model() step d (aggregator catalog search)
  2. list_provider_models('opencode') queries models.dev cache which has mimo-v2.5-free (stale/free-tier slug) — no match for bare mimo-v2.5
  3. Step d fails → resolved_in_current_catalog stays False
  4. Step e detect_provider_for_model('mimo-v2.5', 'opencode') finds it in xiaomi static catalog
  5. Provider switches to xiaomi → needs XIAOMI_API_KEY → 401 → fallback chain → abort

What changed

hermes_cli/model_switch.py

Aggregator catalog fallback (step d): After models.dev catalog search fails to match, added two additional checks:

  1. Check custom_providers model dicts from config.yaml — if the model is listed there, it's authoritative for the user's configured aggregator
  2. Query the live /v1/models endpoint via fetch_endpoint_model_metadata() — the live API returns current model IDs (e.g. mimo-v2.5) not stale models.dev slugs (e.g. mimo-v2.5-free)

Validation override matching: Fixed the custom_providers override in the validation rejection handler to also match entry_name == target_provider and entry_name == normalized_target (e.g. both opencode-zen and opencode), not just custom:<name>. Built-in provider aliases that also appear in custom_providers were invisible to the old check.

Import: Added normalize_provider to the imports from hermes_cli.providers.

How to review

  1. Read the diff in hermes_cli/model_switch.py — two logical changes, both defensive
  2. The aggregator fallback only fires when models.dev catalog search already failed (no regression path)
  3. The live API fallback is wrapped in try/except and only runs for aggregator providers with a base_url

Evidence

# Before fix:
>>> switch_model('mimo-v2.5', current_provider='opencode-zen', ...)
# target_provider: xiaomi ← WRONG (should stay on opencode-zen)

# After fix:
>>> switch_model('mimo-v2.5', current_provider='opencode-zen', custom_providers=[...], ...)
# target_provider: opencode-zen ← CORRECT
# success: True

Tests: 36 passed (1 pre-existing fixture failure unrelated to change).

Verification

  • /model mimo-v2.5 on opencode-zen stays on opencode-zen (no xiaomi fallback)
  • /model deepseek-v4-flash on opencode-go still works (existing pattern)
  • /model kimi-k2.6 on opencode-zen resolves correctly
  • Non-aggregator providers unaffected (step d is aggregator-only)

Risks & gaps

  • Live API fallback adds one HTTP call on model switch when models.dev is stale. This is rare — models.dev usually has the data. The call uses the existing fetch_endpoint_model_metadata() which has its own TTL cache.
  • Doesn't fix the underlying models.dev stale data issue (that's an upstream concern).

Note for reviewers

This is a resubmission of #33695 with only the focused model_switch.py fix (per reviewer feedback to split bundled changes). The unrelated changes (skin_engine.py, utils.py, test_atomic_replace_symlinks.py, agent_init.py) have been removed and will be submitted as separate PRs.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard labels May 28, 2026
@liuhao1024

Copy link
Copy Markdown
Contributor

The aggregator fallback logic looks reasonable — checking custom_providers model dicts before hitting the live API is a good defense-in-depth order. Two observations:

  1. No tests. This adds a new code path (custom_providers iteration + live API fallback + broader provider matching) to switch_model but tests/ is untouched. The live API fallback silently swallows all exceptions (except Exception: pass), which is safe but means a broken fetch_endpoint_model_metadata could go unnoticed indefinitely. A test that mocks fetch_endpoint_model_metadata and verifies resolved_in_current_catalog flips to True would catch regressions.

  2. normalize_provider match may miss custom:-prefixed targets. When target_provider = "custom:openrouter" (the slug format), normalize_provider("custom:openrouter") lowercases to "custom:openrouter" — but entry_name is just "openrouter". The comparison entry_name == normalized_target won't match because one has the prefix and the other doesn't. The entry_slug == target_provider check handles the exact match, but case-insensitive variants like "Custom:OpenRouter" would fall through all four conditions. Consider stripping the "custom:" prefix before normalizing, or adding entry_name.lower() == target_provider.split(":", 1)[-1].lower() as an additional condition.

OmarB97 pushed a commit to OmarB97/hermes-agent that referenced this pull request May 28, 2026
Address PR NousResearch#33716 review feedback:

1. Add tests for the new aggregator fallback code paths:
   - custom_providers model dict iteration resolves models not in models.dev
   - live fetch_endpoint_model_metadata fallback resolves missing models
   - live API exceptions are silently swallowed (except Exception: pass)
   - canonical model casing from live endpoint is preserved

2. Fix custom: prefix slug matching:
   - target_provider='Custom:OpenRouter' now matches entry_name='OpenRouter'
   - Added split(':',1)[-1].lower() suffix comparison as 5th condition
   - Normalise always lowercases so 'custom:openrouter' won't match 'OpenRouter'
     by name alone; the suffix strip handles arbitrary casing.

Closes: NousResearch#33716 review feedback (liuhao1024)
@OmarB97

OmarB97 commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

Both observations addressed in fd00a651e:

  1. Tests added (tests/hermes_cli/test_model_switch_aggregator_fallback.py, 5 tests):

    • test_aggregator_custom_providers_models_dict_resolves_missing_from_catalog — verifies custom_providers model dict iteration flips resolved_in_current_catalog=True
    • test_aggregator_live_api_fallback_resolves_missing_model — mocks fetch_endpoint_model_metadata and verifies canonical casing is preserved
    • test_aggregator_live_api_fallback_swallows_exceptions — confirms ConnectionError from fetch doesn't propagate past the except block
    • test_custom_prefix_case_insensitive_matchCustom:OpenRouter matches entry_name=OpenRouter
    • test_custom_prefix_lowercase_matchcustom:deepseek matches entry_name=DeepSeek
  2. custom: prefix matching fixed — added 5th condition entry_name.lower() == target_provider.split(":", 1)[-1].lower() that strips the prefix before comparing. Now Custom:OpenRouter and custom:openrouter both match entry_name = "OpenRouter".

OmarB97 pushed a commit to OmarB97/hermes-agent that referenced this pull request May 28, 2026
Address PR NousResearch#33716 review feedback:

1. Add tests for the new aggregator fallback code paths:
   - custom_providers model dict iteration resolves models not in models.dev
   - live fetch_endpoint_model_metadata fallback resolves missing models
   - live API exceptions are silently swallowed (except Exception: pass)
   - canonical model casing from live endpoint is preserved

2. Fix custom: prefix slug matching:
   - target_provider='Custom:OpenRouter' now matches entry_name='OpenRouter'
   - Added split(':',1)[-1].lower() suffix comparison as 5th condition
   - Normalise always lowercases so 'custom:openrouter' won't match 'OpenRouter'
     by name alone; the suffix strip handles arbitrary casing.

Closes: NousResearch#33716 review feedback (liuhao1024)
OmarB97 added a commit to OmarB97/hermes-agent that referenced this pull request May 28, 2026
…oxes

* fix: add tests for aggregator fallback + fix custom: prefix matching

Address PR NousResearch#33716 review feedback:

1. Add tests for the new aggregator fallback code paths:
   - custom_providers model dict iteration resolves models not in models.dev
   - live fetch_endpoint_model_metadata fallback resolves missing models
   - live API exceptions are silently swallowed (except Exception: pass)
   - canonical model casing from live endpoint is preserved

2. Fix custom: prefix slug matching:
   - target_provider='Custom:OpenRouter' now matches entry_name='OpenRouter'
   - Added split(':',1)[-1].lower() suffix comparison as 5th condition
   - Normalise always lowercases so 'custom:openrouter' won't match 'OpenRouter'
     by name alone; the suffix strip handles arbitrary casing.

Closes: NousResearch#33716 review feedback (liuhao1024)

* fix: add HERMES_SKIP_PROFILE_OVERRIDE escape hatch for launcher sandboxes

MeshBoard's stream-tap launcher creates a per-dispatch HERMES_HOME
sandbox whose .env points at a local loopback proxy.  Hermes'
_apply_profile_override() was reading ~/.hermes/active_profile and
clobbering the sandbox path, causing every dispatch to bypass the tap.

Add an early return when HERMES_SKIP_PROFILE_OVERRIDE=1 is set in the
environment.  The MeshBoard launcher will set this alongside
HERMES_HOME so the sandbox is honoured verbatim.

Refs meshboard task: hermes-stream-tap-profile-override

---------

Co-authored-by: Omar B <omar@kostudios.io>
@OmarB97 OmarB97 changed the title fix: model_switch aggregator catalog stale models.dev fallback fix(cli): resolve aggregator models via custom_providers and live /v1/models when models.dev is stale Jun 9, 2026
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@OmarB97
OmarB97 force-pushed the fix/model-switch-aggregator-clean branch from fd00a65 to 9eee472 Compare July 5, 2026 18:51

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for isolating the stale-catalog case and incorporating the prior discussion's request for regression coverage. The current main path still needs a live-endpoint fallback: hermes_cli/model_switch.py:1060 only checks list_provider_models(), which is models.dev-backed (agent/models_dev.py:511), before falling through to provider detection at hermes_cli/model_switch.py:1138.

Problems

  • The new custom_providers scan in hermes_cli/model_switch.py:1081 accepts a model from any configured endpoint, without establishing that the entry is the active provider or shares its endpoint. That can leave a user on an aggregator that does not serve the selected model.
  • The added fallback tests assert only successful validation after mocking detect_provider_for_model to return None and validation to accept. They would pass with the fallback removed, so they do not cover the intended anti-hijack guarantee.

Suggested changes

  • Restrict the config match to the active provider's canonical slug or normalized base URL, and add an unrelated-custom-provider negative test.
  • Make the regression tests force the Xiaomi detection fallback absent the new code, then assert the final provider remains OpenCode and the live probe was used.

Automated hermes-sweeper review.

# user's configured aggregators.
if not resolved_in_current_catalog:
new_model_lower = new_model.lower()
# Check custom_providers model dicts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This accepts a model declared by any custom provider, not necessarily the active aggregator. Scope the scan to an entry whose canonical slug or normalized base URL identifies target_provider/the current endpoint; otherwise an unrelated endpoint can suppress provider detection and leave the switch on an aggregator that does not serve the model.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants