fix(models): preserve OpenRouter variant tags (:free, :extended, :fast) during model switch (#6383)
Step c in switch_model() blindly converted the first colon to a slash for aggregator providers, even when the model name already contained a slash (vendor/model format). This mangled variant tags like :free into /free, causing 400 Bad Request from the API. Fix: skip the colon→slash conversion when the model already has a slash, since the colon is a variant tag, not a vendor separator. The module docstring already documented this intent (line 17-18) but the implementation didn't enforce it. Reported via Discord. Related to PR #6088 (which identified the same bug but placed the fix in model_normalize.py instead of model_switch.py where the actual mangling occurs).
This commit is contained in:
@@ -537,8 +537,11 @@ def switch_model(
|
||||
)
|
||||
else:
|
||||
# --- Step c: On aggregator, convert vendor:model to vendor/model ---
|
||||
# Only convert when there's no slash — a slash means the name
|
||||
# is already in vendor/model format and the colon is a variant
|
||||
# tag (:free, :extended, :fast) that must be preserved.
|
||||
colon_pos = raw_input.find(":")
|
||||
if colon_pos > 0 and is_aggregator(current_provider):
|
||||
if colon_pos > 0 and "/" not in raw_input and is_aggregator(current_provider):
|
||||
left = raw_input[:colon_pos].strip().lower()
|
||||
right = raw_input[colon_pos + 1:].strip()
|
||||
if left and right:
|
||||
|
||||
70
tests/hermes_cli/test_model_switch_variant_tags.py
Normal file
70
tests/hermes_cli/test_model_switch_variant_tags.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Tests for OpenRouter variant tag preservation in model switching.
|
||||
|
||||
Regression test for GitHub PR #6088 / Discord report: OpenRouter model IDs
|
||||
with variant suffixes like ``:free``, ``:extended``, ``:fast`` were being
|
||||
mangled by the colon-to-slash conversion in model_switch.py Step c.
|
||||
|
||||
The fix: Step c now skips colon→slash conversion when the model name already
|
||||
contains a forward slash (i.e. is already in ``vendor/model`` format), since
|
||||
the colon is a variant tag, not a vendor separator.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.model_switch import switch_model
|
||||
|
||||
|
||||
# Shared mock context — skip network calls, credential resolution, catalog lookups
|
||||
_MOCK_VALIDATION = {"accepted": True, "persist": True, "recognized": True, "message": None}
|
||||
|
||||
|
||||
def _run_switch(raw_input: str, current_provider: str = "openrouter") -> str:
|
||||
"""Run switch_model with mocked dependencies, return the resolved model name."""
|
||||
with patch("hermes_cli.model_switch.resolve_alias", return_value=None), \
|
||||
patch("hermes_cli.model_switch.list_provider_models", return_value=[]), \
|
||||
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={"api_key": "test", "base_url": "", "api_mode": "chat_completions"}), \
|
||||
patch("hermes_cli.models.validate_requested_model", return_value=_MOCK_VALIDATION), \
|
||||
patch("hermes_cli.model_switch.get_model_info", return_value=None), \
|
||||
patch("hermes_cli.model_switch.get_model_capabilities", return_value=None), \
|
||||
patch("hermes_cli.models.detect_provider_for_model", return_value=None):
|
||||
result = switch_model(
|
||||
raw_input=raw_input,
|
||||
current_provider=current_provider,
|
||||
current_model="anthropic/claude-sonnet-4.6",
|
||||
)
|
||||
assert result.success, f"switch_model failed: {result.error_message}"
|
||||
return result.new_model
|
||||
|
||||
|
||||
class TestVariantTagPreservation:
|
||||
"""OpenRouter variant tags (:free, :extended, :fast) must survive model switching."""
|
||||
|
||||
@pytest.mark.parametrize("model,expected", [
|
||||
("nvidia/nemotron-3-super-120b-a12b:free", "nvidia/nemotron-3-super-120b-a12b:free"),
|
||||
("anthropic/claude-sonnet-4.6:extended", "anthropic/claude-sonnet-4.6:extended"),
|
||||
("meta-llama/llama-4-maverick:fast", "meta-llama/llama-4-maverick:fast"),
|
||||
])
|
||||
def test_slash_format_preserves_variant_tag(self, model, expected):
|
||||
"""Models already in vendor/model:tag format must not have their tag mangled."""
|
||||
assert _run_switch(model) == expected
|
||||
|
||||
def test_legacy_colon_format_converts_to_slash(self):
|
||||
"""Legacy vendor:model (no slash) should still be converted to vendor/model."""
|
||||
result = _run_switch("nvidia:nemotron-3-super-120b-a12b")
|
||||
assert result == "nvidia/nemotron-3-super-120b-a12b"
|
||||
|
||||
def test_legacy_colon_format_with_tag_converts_first_colon_only(self):
|
||||
"""vendor:model:free (no slash) → vendor/model:free — first colon becomes slash."""
|
||||
result = _run_switch("nvidia:nemotron-3-super-120b-a12b:free")
|
||||
assert result == "nvidia/nemotron-3-super-120b-a12b:free"
|
||||
|
||||
def test_bare_model_name_unaffected(self):
|
||||
"""Bare model names without colons or slashes should work normally."""
|
||||
result = _run_switch("claude-sonnet-4.6")
|
||||
assert result == "anthropic/claude-sonnet-4.6"
|
||||
|
||||
def test_already_correct_slug_no_tag(self):
|
||||
"""Standard vendor/model slugs without tags pass through unchanged."""
|
||||
result = _run_switch("anthropic/claude-sonnet-4.6")
|
||||
assert result == "anthropic/claude-sonnet-4.6"
|
||||
Reference in New Issue
Block a user