From a24789d738b1074786f58952e299818b41da596e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 6 May 2026 09:08:33 -0700 Subject: [PATCH] fix(opencode-go): keep users on opencode-go instead of hijacking to native providers (#20802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode Go and OpenCode Zen are flat-namespace model resellers — their /v1/models returns bare IDs (deepseek-v4-flash, minimax-m2.7), and the inference API rejects vendor-prefixed names with HTTP 401 'Model not supported'. Two bugs fixed: 1. `switch_model` in hermes_cli/model_switch.py was silently switching the user off opencode-go to native deepseek when they typed `/model deepseek-v4-flash`. Step d found the model in opencode-go's live catalog, but step e (detect_provider_for_model) still ran and matched the bare name against deepseek's static catalog. Fix: track whether the live catalog resolved it; skip step e when it did. 2. `normalize_model_for_provider` in hermes_cli/model_normalize.py only stripped the exact `opencode-zen/` prefix, leaving arbitrary vendor prefixes like `minimax/minimax-m2.7` (commonly copied from aggregator slugs into fallback_model configs) intact — causing HTTP 401s when the fallback chain activated. Fix: opencode-go/opencode-zen strip ANY leading vendor prefix because their APIs are flat-namespace. Tests: 11 new cases in tests/hermes_cli/test_opencode_go_flat_namespace.py covering both normalization (prefix stripping, regression guards for opencode-zen Claude hyphenation and openrouter vendor-prepending) and switch_model (bare-name resolution on opencode-go's live catalog must not trigger cross-provider hijack). Reported by @Ufonik via Discord; Kimi K2.6 always worked because moonshotai has no overlapping entry in a native provider's static catalog. Deepseek and minimax failed because their v4/v2.7 names existed in the native deepseek/minimax catalogs. --- hermes_cli/model_normalize.py | 23 ++- hermes_cli/model_switch.py | 9 + .../test_opencode_go_flat_namespace.py | 159 ++++++++++++++++++ 3 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 tests/hermes_cli/test_opencode_go_flat_namespace.py diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 433e342796..0e74db718d 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -393,14 +393,21 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str: if provider in _AGGREGATOR_PROVIDERS: return _prepend_vendor(name) - # --- OpenCode Zen: Claude stays hyphenated; other models keep dots --- - if provider == "opencode-zen": - bare = _strip_matching_provider_prefix(name, provider) - if "/" in bare: - return bare - if bare.lower().startswith("claude-"): - return _dots_to_hyphens(bare) - return bare + # --- OpenCode Zen / OpenCode Go: flat-namespace resellers. + # Their /v1/models API returns bare IDs only (no vendor prefix), and + # the inference endpoint rejects vendor-prefixed names with HTTP 401 + # "Model not supported". Strip ANY leading ``vendor/`` so config + # entries like ``minimax/minimax-m2.7`` or ``deepseek/deepseek-v4-flash`` + # — commonly copied from aggregator slugs into fallback_model lists — + # resolve to bare ``minimax-m2.7`` / ``deepseek-v4-flash`` the API + # actually serves. See PR reviewing opencode-go fallback 401s. --- + if provider in {"opencode-zen", "opencode-go"}: + if "/" in name: + _, bare_after_slash = name.split("/", 1) + name = bare_after_slash.strip() or name + if provider == "opencode-zen" and name.lower().startswith("claude-"): + return _dots_to_hyphens(name) + return name # --- Anthropic: strip matching provider prefix, dots -> hyphens --- if provider in _DOT_TO_HYPHEN_PROVIDERS: diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index dfaae1448a..29097f5b2e 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -799,6 +799,12 @@ def switch_model( ) # --- Step d: Aggregator catalog search --- + # Track whether the live catalog of the CURRENT provider resolved the + # model — if so, step e must not second-guess and switch providers. + # Critical for flat-namespace resellers like opencode-go / opencode-zen + # whose live /v1/models returns bare IDs (e.g. "deepseek-v4-flash") that + # coincidentally match entries in native providers' static catalogs. + resolved_in_current_catalog = False if is_aggregator(target_provider) and not resolved_alias: catalog = list_provider_models(target_provider) if catalog: @@ -806,6 +812,7 @@ def switch_model( for mid in catalog: if mid.lower() == new_model_lower: new_model = mid + resolved_in_current_catalog = True break else: for mid in catalog: @@ -813,6 +820,7 @@ def switch_model( _, bare = mid.split("/", 1) if bare.lower() == new_model_lower: new_model = mid + resolved_in_current_catalog = True break # --- Step e: detect_provider_for_model() as last resort --- @@ -825,6 +833,7 @@ def switch_model( target_provider == current_provider and not is_custom and not resolved_alias + and not resolved_in_current_catalog ): detected = detect_provider_for_model(new_model, current_provider) if detected: diff --git a/tests/hermes_cli/test_opencode_go_flat_namespace.py b/tests/hermes_cli/test_opencode_go_flat_namespace.py new file mode 100644 index 0000000000..86500be3e9 --- /dev/null +++ b/tests/hermes_cli/test_opencode_go_flat_namespace.py @@ -0,0 +1,159 @@ +"""Tests for opencode-go / opencode-zen flat-namespace model handling. + +OpenCode Go is NOT a vendor/model aggregator like OpenRouter — its +``/v1/models`` endpoint returns bare IDs (``minimax-m2.7``, ``deepseek-v4-flash``) +and the inference API rejects vendor-prefixed names with HTTP 401 +"Model not supported". + +Two bugs this exercises: + +1. ``switch_model('deepseek-v4-flash', current_provider='opencode-go')`` used + to silently switch the user off opencode-go to native ``deepseek`` because + ``detect_provider_for_model`` matched the bare name against the static + deepseek catalog. Fix: once step d matches the model in the current + aggregator's live catalog, skip ``detect_provider_for_model``. + +2. ``normalize_model_for_provider('minimax/minimax-m2.7', 'opencode-go')`` + used to pass the ``minimax/`` prefix through unchanged. When user configs + contained prefixed fallback entries (commonly copied from aggregator slugs), + the fallback activation path sent ``minimax/minimax-m2.7`` to opencode-go + which returned HTTP 401. Fix: opencode-go/opencode-zen strip ANY leading + ``vendor/`` prefix because their APIs are flat-namespace. +""" + +from unittest.mock import patch + +from hermes_cli.model_normalize import normalize_model_for_provider +from hermes_cli.model_switch import switch_model + + +# Live catalog opencode-go currently returns from /v1/models (snapshot). +_OPENCODE_GO_LIVE = [ + "minimax-m2.7", "minimax-m2.5", + "kimi-k2.6", "kimi-k2.5", + "glm-5.1", "glm-5", + "deepseek-v4-pro", "deepseek-v4-flash", + "qwen3.6-plus", "qwen3.5-plus", + "mimo-v2-pro", "mimo-v2-omni", "mimo-v2.5-pro", "mimo-v2.5", +] + + +# --------------------------------------------------------------------------- +# normalize_model_for_provider: strip vendor prefix for flat-namespace providers +# --------------------------------------------------------------------------- + + +def test_opencode_go_strips_deepseek_prefix(): + assert normalize_model_for_provider( + "deepseek/deepseek-v4-flash", "opencode-go" + ) == "deepseek-v4-flash" + + +def test_opencode_go_strips_minimax_prefix(): + assert normalize_model_for_provider( + "minimax/minimax-m2.7", "opencode-go" + ) == "minimax-m2.7" + + +def test_opencode_go_strips_moonshotai_prefix(): + # Moonshot's aggregator vendor is `moonshotai/...` — a common copy-paste + # from OpenRouter slugs. opencode-go serves it bare as `kimi-k2.6`. + assert normalize_model_for_provider( + "moonshotai/kimi-k2.6", "opencode-go" + ) == "kimi-k2.6" + + +def test_opencode_go_bare_name_unchanged(): + assert normalize_model_for_provider( + "kimi-k2.6", "opencode-go" + ) == "kimi-k2.6" + + +def test_opencode_go_preserves_dot_versioning(): + # opencode-go uses dot-versioned IDs (`mimo-v2.5-pro`, not hyphen). + assert normalize_model_for_provider( + "xiaomi/mimo-v2.5-pro", "opencode-go" + ) == "mimo-v2.5-pro" + + +def test_opencode_zen_still_hyphenates_claude(): + # Regression: opencode-zen's Claude hyphen conversion must still work. + assert normalize_model_for_provider( + "anthropic/claude-sonnet-4.6", "opencode-zen" + ) == "claude-sonnet-4-6" + + +def test_opencode_zen_bare_claude_hyphenated(): + assert normalize_model_for_provider( + "claude-sonnet-4.6", "opencode-zen" + ) == "claude-sonnet-4-6" + + +def test_opencode_zen_strips_arbitrary_vendor_prefix(): + assert normalize_model_for_provider( + "minimax/minimax-m2.5-free", "opencode-zen" + ) == "minimax-m2.5-free" + + +def test_openrouter_still_prepends_vendor(): + # Regression: real aggregators must still get vendor/model format. + assert normalize_model_for_provider( + "claude-sonnet-4.6", "openrouter" + ) == "anthropic/claude-sonnet-4.6" + + +# --------------------------------------------------------------------------- +# switch_model: live-catalog match on opencode-go must not trigger +# cross-provider auto-switch via detect_provider_for_model +# --------------------------------------------------------------------------- + + +def _run_switch(raw_input: str, **extra): + """Call switch_model with opencode-go as current provider, mocking the + live catalog so the test doesn't hit the network.""" + defaults = dict( + current_provider="opencode-go", + current_model="kimi-k2.6", + current_base_url="https://opencode.ai/zen/go/v1", + current_api_key="sk-test-opencode-go", + is_global=False, + ) + defaults.update(extra) + + def fake_list_provider_models(provider: str): + if provider == "opencode-go": + return list(_OPENCODE_GO_LIVE) + # For other providers, return empty so tests don't depend on them. + return [] + + with patch( + "hermes_cli.model_switch.list_provider_models", + side_effect=fake_list_provider_models, + ): + return switch_model(raw_input=raw_input, **defaults) + + +def test_deepseek_v4_flash_stays_on_opencode_go(): + """Regression: ``/model deepseek-v4-flash`` while on opencode-go must + NOT switch to native deepseek just because deepseek's static catalog + also contains that name.""" + result = _run_switch("deepseek-v4-flash") + assert result.target_provider == "opencode-go", ( + f"Expected to stay on opencode-go, got {result.target_provider}. " + f"detect_provider_for_model hijacked the bare name." + ) + assert result.new_model == "deepseek-v4-flash" + + +def test_deepseek_v4_pro_stays_on_opencode_go(): + """Same bug class as the flash variant.""" + result = _run_switch("deepseek-v4-pro") + assert result.target_provider == "opencode-go" + assert result.new_model == "deepseek-v4-pro" + + +def test_kimi_k2_6_stays_on_opencode_go(): + """Regression guard: this path was always working, keep it working.""" + result = _run_switch("kimi-k2.6", current_model="deepseek-v4-pro") + assert result.target_provider == "opencode-go" + assert result.new_model == "kimi-k2.6"