Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 48 additions & 48 deletions tests/test_issue1894_provider_overlap.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
# Copyright 2025 the Hermes WebUI contributors
# SPDX-License-Identifier: MIT

# noqa: N801

# Regression tests for GitHub issue #1894.
#
# Symptom: when the WebUI's configured provider (e.g. `opencode-go`) and a
# `custom_providers[]` entry both expose the same bare model id (e.g.
# `deepseek-v4-pro`), the resolver was routing to `custom:<name>` instead of
# the configured `opencode-go` endpoint.
#
# Root cause: `resolve_model_provider()` in `api/config.py` guarded the custom-
# provider skip only when `model_id == model.default`. If `model.default`
# was a different model (e.g. `glm-5.1`), the overlap was not detected and
# `deepseek-v4-pro` was matched against `custom_providers[]` first, routing
# the WebUI to the wrong endpoint.
#
# Fix: widen the guard so an explicit non-custom provider wins for any model
# it owns in `_PROVIDER_MODELS[config_provider]`.
"""Regression tests for GitHub issue #1894.

Symptom: when the WebUI's configured provider (e.g. ``opencode-go``) and a
``custom_providers[]`` entry both expose the same bare model id (e.g.
``deepseek-v4-pro``), the resolver was routing to ``custom:<name>`` instead of
the configured ``opencode-go`` endpoint.

Root cause: ``resolve_model_provider()`` in ``api/config.py`` guarded the
custom-provider skip only when ``model_id == model.default``. If
``model.default`` was a different model (e.g. ``glm-5.1``), the overlap was
not detected and ``deepseek-v4-pro`` was matched against
``custom_providers[]`` first, routing the WebUI to the wrong endpoint.

Fix: widen the guard so an explicit non-custom provider wins for any model
it owns in ``_PROVIDER_MODELS[config_provider]``.
"""

from api.config import resolve_model_provider, model_with_provider_context

Expand All @@ -44,18 +43,17 @@ def _restore_config(cfg_module, old_model, old_custom):
cfg_module.cfg['custom_providers'] = old_custom


# ---------------------------------------------------------------------------
# Case 1 — overlap: selected non-custom provider should win
# ---------------------------------------------------------------------------

def test_selected_opencode_go_wins_over_custom_provider_overlap():
# opencode-go and a custom DeepSeek-compatible endpoint both serve
# deepseek-v4-pro. With opencode-go configured as the active provider,
# selection of deepseek-v4-pro must route to opencode-go, not to the
# custom endpoint.
"""Case 1 — overlap: selected non-custom provider should win.

OpenCode Go and a custom DeepSeek-compatible endpoint both serve
deepseek-v4-pro. With opencode-go configured as the active provider,
selection of deepseek-v4-pro must route to opencode-go, not to the
custom endpoint.
"""
import api.config as cfg_mod
old_model, old_custom = _apply_config_overrides(cfg_mod, {
'base_url': 'https://api.opencode.ai/go/v1',
'base_url': 'https://opencode.ai/zen/go/v1',
})
cfg_mod.cfg['custom_providers'] = [{
'name': 'ds2api',
Expand All @@ -71,7 +69,7 @@ def test_selected_opencode_go_wins_over_custom_provider_overlap():
f'Expected provider=opencode-go, got provider={provider!r}. '
f'WebUI was routed to custom provider instead.'
)
assert base_url == 'https://api.opencode.ai/go/v1', (
assert base_url == 'https://opencode.ai/zen/go/v1', (
f'Expected base_url from opencode-go config, got {base_url!r}'
)
assert model == 'deepseek-v4-pro'
Expand All @@ -80,11 +78,14 @@ def test_selected_opencode_go_wins_over_custom_provider_overlap():


def test_selected_opencode_go_wins_direct_resolve():
# Same scenario but bypassing model_with_provider_context to test the
# resolver path directly with a bare model id.
"""Case 1 variant — same overlap scenario via direct resolve path.

Bypasses model_with_provider_context to test the resolver path directly
with a bare model id.
"""
import api.config as cfg_mod
old_model, old_custom = _apply_config_overrides(cfg_mod, {
'base_url': 'https://api.opencode.ai/go/v1',
'base_url': 'https://opencode.ai/zen/go/v1',
})
cfg_mod.cfg['custom_providers'] = [{
'name': 'ds2api',
Expand All @@ -96,21 +97,20 @@ def test_selected_opencode_go_wins_direct_resolve():
assert provider == 'opencode-go', (
f'Expected provider=opencode-go, got provider={provider!r}'
)
assert base_url == 'https://api.opencode.ai/go/v1'
assert base_url == 'https://opencode.ai/zen/go/v1'
finally:
_restore_config(cfg_mod, old_model, old_custom)


# ---------------------------------------------------------------------------
# Case 2 — custom-only model: custom provider routing must stay intact
# ---------------------------------------------------------------------------

def test_custom_only_model_still_routes_to_custom_provider():
# A model that exists only in a custom provider must still be routed
# correctly when no explicit provider prefix is given.
"""Case 2 — custom-only model routing must stay intact.

A model that exists only in a custom provider must still be routed
correctly when no explicit provider prefix is given.
"""
import api.config as cfg_mod
old_model, old_custom = _apply_config_overrides(cfg_mod, {
'base_url': 'https://api.opencode.ai/go/v1',
'base_url': 'https://opencode.ai/zen/go/v1',
})
cfg_mod.cfg['custom_providers'] = [{
'name': 'ds2api',
Expand All @@ -127,22 +127,22 @@ def test_custom_only_model_still_routes_to_custom_provider():
_restore_config(cfg_mod, old_model, old_custom)


# ---------------------------------------------------------------------------
# Case 3 — explicit custom provider selection still works
# ---------------------------------------------------------------------------

def test_explicit_custom_provider_selection_intact():
# @custom:<name>:<model> syntax must not be swallowed by the new guard.
"""Case 3 — explicit custom provider selection still works.

The @custom:<name>:<model> syntax must not be swallowed by the new guard.
"""
model, provider, base_url = resolve_model_provider('@custom:ds2api:deepseek-v4-pro')
assert provider == 'custom:ds2api', f'Expected provider=custom:ds2api, got {provider!r}'
assert model == 'deepseek-v4-pro'


# ---------------------------------------------------------------------------
# Case 4 — existing suffix syntax is preserved
# ---------------------------------------------------------------------------

def test_openrouter_suffix_still_works():
"""Case 4 — existing suffix syntax is preserved.

Ensures the openrouter suffix syntax (e.g. model_name:free) still routes
correctly through model_with_provider_context.
"""
import api.config as cfg_mod
old_model, old_custom = _apply_config_overrides(cfg_mod, {
'provider': 'anthropic', # non-openrouter so prefix is needed
Expand All @@ -154,4 +154,4 @@ def test_openrouter_suffix_still_works():
assert provider == 'openrouter'
assert model == 'tencent/hy3-preview:free'
finally:
_restore_config(cfg_mod, old_model, old_custom)
_restore_config(cfg_mod, old_model, old_custom)