From 7532482393f7656b27e276cc9bc8c8c399baac62 Mon Sep 17 00:00:00 2001 From: liyang1116 Date: Sat, 9 May 2026 16:16:32 +0800 Subject: [PATCH] fix: fix(config): skip #1776 provider peel for custom host:port slugs model_with_provider_context can emit @custom::: when model_provider is derived from an OpenAI base_url authority (e.g. custom:10.8.0.1:8080). The colon-count heuristic meant for @custom:slug:model:free mistook those extra colons for an over-split model ID and prepended the port segment onto the bare model (8080:Qwen3-235B), breaking WebUI while CLI/curl stayed correct. Detect endpoint-style slugs (IPv4/localhost/hostname + numeric port) and skip the peel in that case. Add regression tests for IPv4, dotted hostname, localhost, and model_with_provider_context round-trip. --- api/config.py | 53 +++++++++++++++++-- ...test_resolve_model_provider_free_suffix.py | 38 +++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/api/config.py b/api/config.py index 27de96d2..d7ac4c98 100644 --- a/api/config.py +++ b/api/config.py @@ -1430,6 +1430,44 @@ def _base_url_points_at_local_server(base_url: str) -> bool: return False +def _custom_slug_rest_looks_like_host_port(rest: str) -> bool: + """True when ``custom:`` is an endpoint-style slug ``host:port``. + + WebUI sometimes derives ``custom:10.8.71.41:8080`` from ``base_url`` authority. + The #1776 peel must not treat that middle colon as part of an eaten model + segment — otherwise ``@custom:10.8.71.41:8080:Qwen3`` wrongly becomes model + ``8080:Qwen3``. + """ + rest = str(rest or "").strip() + if ":" not in rest: + return False + host, port_s = rest.rsplit(":", 1) + if not host or ":" in host: + return False + if not port_s.isdigit(): + return False + try: + port_n = int(port_s) + except ValueError: + return False + if not (1 <= port_n <= 65535): + return False + try: + import ipaddress + + ipaddress.ip_address(host) + return True + except ValueError: + pass + hl = host.lower() + if hl == "localhost": + return True + # Typical DNS hostname used as proxy slug (contains at least one label dot). + if "." in host: + return True + return False + + def resolve_model_provider(model_id: str) -> tuple: """Resolve model name, provider, and base_url for AIAgent. @@ -1516,15 +1554,20 @@ def resolve_model_provider(model_id: str) -> tuple: # ("@custom:my-key:some-model:free"), rsplit yields # provider_hint="custom:my-key:some-model", bare_model="free", and the # custom-prefix guard below skips the split-fallback. Detect the - # over-split structurally — custom hints carry exactly one segment after - # "custom:", so any provider_hint with 2+ colons that starts with - # "custom:" has eaten part of the model name. Peel one segment back. + # over-split structurally — custom hints normally carry one slug segment + # after ``custom:``. If ``provider_hint`` has extra ``:`` tokens because the + # model ID contained tags like ``:free``, peel one segment back (#1776). + # + # Exception: ``custom::`` is a single logical slug derived + # from OpenAI ``base_url`` authority and contains no eaten model segments. if model_id.startswith("@") and ":" in model_id: inner = model_id[1:] provider_hint, bare_model = inner.rsplit(":", 1) if provider_hint.startswith("custom:") and provider_hint.count(":") >= 2: - provider_hint, extra = provider_hint.rsplit(":", 1) - bare_model = f"{extra}:{bare_model}" + _slug_rest = provider_hint[len("custom:"):] + if not _custom_slug_rest_looks_like_host_port(_slug_rest): + provider_hint, extra = provider_hint.rsplit(":", 1) + bare_model = f"{extra}:{bare_model}" elif (provider_hint not in _PROVIDER_MODELS and provider_hint not in _PROVIDER_DISPLAY and not provider_hint.startswith("custom:")): diff --git a/tests/test_resolve_model_provider_free_suffix.py b/tests/test_resolve_model_provider_free_suffix.py index 9d9d0760..8798b71e 100644 --- a/tests/test_resolve_model_provider_free_suffix.py +++ b/tests/test_resolve_model_provider_free_suffix.py @@ -170,3 +170,41 @@ def test_custom_provider_slashed_model_with_free_suffix_1776(): model, provider, _ = resolve_model_provider(qualified) assert provider == "custom:my-key" assert model == "org/model:free" + + +def test_custom_provider_ipv4_port_slug_no_false_peel(): + """host:port in custom slug must not trigger #1776 peel — avoids ``8080:model``.""" + qualified = "@custom:10.8.71.41:8080:Qwen3-235B" + model, provider, _ = resolve_model_provider(qualified) + assert provider == "custom:10.8.71.41:8080" + assert model == "Qwen3-235B" + + +def test_custom_provider_hostname_port_slug_no_false_peel(): + qualified = "@custom:proxy.internal:8443:Qwen3-235B" + model, provider, _ = resolve_model_provider(qualified) + assert provider == "custom:proxy.internal:8443" + assert model == "Qwen3-235B" + + +def test_custom_provider_localhost_port_slug_no_false_peel(): + qualified = "@custom:localhost:11434:llama3.2" + model, provider, _ = resolve_model_provider(qualified) + assert provider == "custom:localhost:11434" + assert model == "llama3.2" + + +def test_model_with_provider_context_custom_ipv4_port_roundtrip(): + """Mirrors WebUI /start payload: bare model + custom:: provider.""" + import api.config as cfg_mod + + old = dict(cfg_mod.cfg.get("model", {})) + cfg_mod.cfg["model"] = {"provider": "custom", "default": "gpt-5.5"} + try: + wrapped = model_with_provider_context("Qwen3-235B", "custom:10.8.71.41:8080") + assert wrapped == "@custom:10.8.71.41:8080:Qwen3-235B" + model, provider, _ = resolve_model_provider(wrapped) + assert provider == "custom:10.8.71.41:8080" + assert model == "Qwen3-235B" + finally: + cfg_mod.cfg["model"] = old