diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 2c4988cc76ea..8c4a31b25d2e 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -12,9 +12,11 @@ Different LLM providers expect model identifiers in different formats: model IDs, but Claude still uses hyphenated native names like ``claude-sonnet-4-6``. - **OpenCode Go** preserves dots in model names: ``minimax-m2.7``. -- **DeepSeek** accepts ``deepseek-chat`` (V3), ``deepseek-reasoner`` - (R1-family), and the first-class V-series IDs (``deepseek-v4-pro``, - ``deepseek-v4-flash``, and any future ``deepseek-v-*``). Older +- **DeepSeek** accepts only the first-class V-series IDs + (``deepseek-v4-pro``, ``deepseek-v4-flash``, and any future + ``deepseek-v-*``). The legacy aliases ``deepseek-chat`` and + ``deepseek-reasoner`` were retired on 2026-07-24 and are remapped to + ``deepseek-v4-flash`` (official non-thinking / thinking shims). Older Hermes revisions folded every non-reasoner input into ``deepseek-chat``, which on aggregators routes to V3 — so a user picking V4 Pro was silently downgraded. @@ -118,8 +120,9 @@ _LOWERCASE_MODEL_PROVIDERS: frozenset[str] = frozenset({ # --------------------------------------------------------------------------- # DeepSeek special handling # --------------------------------------------------------------------------- -# DeepSeek's API only recognises exactly two model identifiers. We map -# common aliases and patterns to the canonical names. +# DeepSeek's direct API only accepts first-class V-series IDs after the +# 2026-07-24 cut-off. Legacy aliases and fuzzy names are remapped here so +# saved configs / picker leftovers cannot keep sending retired IDs. _DEEPSEEK_REASONER_KEYWORDS: frozenset[str] = frozenset({ "reasoner", @@ -129,9 +132,15 @@ _DEEPSEEK_REASONER_KEYWORDS: frozenset[str] = frozenset({ "cot", }) +# Retired on 2026-07-24 15:59 UTC. Official docs: both aliases mapped to +# deepseek-v4-flash (chat = non-thinking, reasoner = thinking). Thinking +# mode itself is controlled by extra_body.thinking on the DeepSeek profile. +_DEEPSEEK_RETIRED_ALIASES: frozenset[str] = frozenset({ + "deepseek-chat", + "deepseek-reasoner", +}) + _DEEPSEEK_CANONICAL_MODELS: frozenset[str] = frozenset({ - "deepseek-chat", # V3 on DeepSeek direct and most aggregators - "deepseek-reasoner", # R1-family reasoning model "deepseek-v4-pro", # V4 Pro — first-class model ID "deepseek-v4-flash", # V4 Flash — first-class model ID }) @@ -149,13 +158,15 @@ def _normalize_for_deepseek(model_name: str) -> str: """Map a model input to a DeepSeek-accepted identifier. Rules: - - Already a known canonical (``deepseek-chat``/``deepseek-reasoner``/ - ``deepseek-v4-pro``/``deepseek-v4-flash``) -> pass through. + - Retired aliases ``deepseek-chat`` / ``deepseek-reasoner`` (cut off + 2026-07-24) -> ``deepseek-v4-flash``. + - Already a known canonical (``deepseek-v4-pro``/``deepseek-v4-flash``) + -> pass through. - Matches the V-series pattern ``deepseek-v...`` -> pass through (covers future ``deepseek-v5-*`` and dated variants without a release). - Contains a reasoner keyword (r1, think, reasoning, cot, reasoner) - -> ``deepseek-reasoner``. - - Everything else -> ``deepseek-chat``. + -> ``deepseek-v4-flash``. + - Everything else -> ``deepseek-v4-flash``. Args: model_name: The bare model name (vendor prefix already stripped). @@ -165,6 +176,11 @@ def _normalize_for_deepseek(model_name: str) -> str: """ bare = _strip_vendor_prefix(model_name).lower() + # Retired aliases must rewrite — DeepSeek returns HTTP 400 after the + # 2026-07-24 cut-off if these IDs are sent on the wire. + if bare in _DEEPSEEK_RETIRED_ALIASES: + return "deepseek-v4-flash" + if bare in _DEEPSEEK_CANONICAL_MODELS: return bare @@ -175,9 +191,9 @@ def _normalize_for_deepseek(model_name: str) -> str: # Check for reasoner-like keywords anywhere in the name for keyword in _DEEPSEEK_REASONER_KEYWORDS: if keyword in bare: - return "deepseek-reasoner" + return "deepseek-v4-flash" - return "deepseek-chat" + return "deepseek-v4-flash" # --------------------------------------------------------------------------- @@ -369,10 +385,13 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str: 'minimax-m2.5-free' >>> normalize_model_for_provider("deepseek-v3", "deepseek") - 'deepseek-chat' + 'deepseek-v4-flash' >>> normalize_model_for_provider("deepseek-r1", "deepseek") - 'deepseek-reasoner' + 'deepseek-v4-flash' + + >>> normalize_model_for_provider("deepseek-reasoner", "deepseek") + 'deepseek-v4-flash' >>> normalize_model_for_provider("my-model", "custom") 'my-model' diff --git a/tests/hermes_cli/test_model_normalize.py b/tests/hermes_cli/test_model_normalize.py index 77ece2101150..564c188e96e4 100644 --- a/tests/hermes_cli/test_model_normalize.py +++ b/tests/hermes_cli/test_model_normalize.py @@ -232,19 +232,33 @@ class TestDeepseekVSeriesPassThrough: assert result == "deepseek-v4-flash" -# ── DeepSeek regressions (existing behaviour still holds) ────────────── +# ── DeepSeek post-2026-07-24 alias remapping ─────────────────────────── class TestDeepseekCanonicalAndReasonerMapping: - """Canonical pass-through and reasoner-keyword folding stay intact.""" + """Retired aliases and fuzzy names rewrite to deepseek-v4-flash. + + DeepSeek cut off ``deepseek-chat`` / ``deepseek-reasoner`` on + 2026-07-24; sending them on the wire returns HTTP 400. + """ @pytest.mark.parametrize("model,expected", [ - ("deepseek-chat", "deepseek-chat"), - ("deepseek-reasoner", "deepseek-reasoner"), - ("DEEPSEEK-CHAT", "deepseek-chat"), + ("deepseek-chat", "deepseek-v4-flash"), + ("deepseek-reasoner", "deepseek-v4-flash"), + ("DEEPSEEK-CHAT", "deepseek-v4-flash"), + ("DEEPSEEK-REASONER", "deepseek-v4-flash"), + ("deepseek/deepseek-reasoner", "deepseek-v4-flash"), + ("deepseek-v4-pro", "deepseek-v4-pro"), + ("deepseek-v4-flash", "deepseek-v4-flash"), ]) - def test_canonical_models_pass_through(self, model, expected): + def test_retired_aliases_and_canonicals(self, model, expected): assert _normalize_for_deepseek(model) == expected + def test_provider_path_rewrites_reasoner(self): + assert ( + normalize_model_for_provider("deepseek-reasoner", "deepseek") + == "deepseek-v4-flash" + ) + @pytest.mark.parametrize("model", [ "deepseek-r1", "deepseek-r1-0528", @@ -252,8 +266,8 @@ class TestDeepseekCanonicalAndReasonerMapping: "deepseek-reasoning-preview", "deepseek-cot-experimental", ]) - def test_reasoner_keywords_map_to_reasoner(self, model): - assert _normalize_for_deepseek(model) == "deepseek-reasoner" + def test_reasoner_keywords_map_to_v4_flash(self, model): + assert _normalize_for_deepseek(model) == "deepseek-v4-flash" @pytest.mark.parametrize("model", [ "deepseek-chat-v3.1", # 'chat' prefix, not V-series pattern @@ -261,5 +275,5 @@ class TestDeepseekCanonicalAndReasonerMapping: "something-random", "gpt-5", # non-DeepSeek names still fall through ]) - def test_unknown_names_fall_back_to_chat(self, model): - assert _normalize_for_deepseek(model) == "deepseek-chat" + def test_unknown_names_fall_back_to_v4_flash(self, model): + assert _normalize_for_deepseek(model) == "deepseek-v4-flash"