diff --git a/api/config.py b/api/config.py index c3c87f4b..ab304318 100644 --- a/api/config.py +++ b/api/config.py @@ -1480,9 +1480,31 @@ def _custom_slug_rest_looks_like_host_port(rest: str) -> bool: def _get_provider_base_url(provider_id): - """Look up the configured base_url for a provider (e.g. lmstudio).""" + """Look up the configured base_url for a provider (e.g. lmstudio). + + Checks two locations, in order: + 1. ``cfg["providers"][]["base_url"]`` — the explicit + per-provider override. + 2. ``cfg["model"]["base_url"]`` — falls back here when + ``cfg["model"]["provider"] == provider_id``. This is the historical + shape (the model block carries both the active provider AND the + base URL for that provider in a single record). + + Returns the URL stripped of trailing ``/`` if configured, otherwise None. + """ prov_cfg = cfg.get("providers", {}).get(provider_id, {}) or {} - return (prov_cfg.get("base_url") or "").rstrip("/") or None + explicit = (prov_cfg.get("base_url") or "").strip().rstrip("/") + if explicit: + return explicit + model_cfg = cfg.get("model", {}) or {} + if isinstance(model_cfg, dict): + model_provider = str(model_cfg.get("provider") or "").strip().lower() + if model_provider == str(provider_id).strip().lower(): + model_base = (model_cfg.get("base_url") or "").strip().rstrip("/") + if model_base: + return model_base + return None + def resolve_model_provider(model_id: str) -> tuple: """Resolve model name, provider, and base_url for AIAgent. @@ -3415,7 +3437,7 @@ def get_available_models() -> dict: # the profile's .env has been injected into the process environment. lm_cfg = cfg.get("providers", {}).get("lmstudio", {}) if isinstance(lm_cfg, dict): - lm_base_url = str(lm_cfg.get("base_url") or "").strip().rstrip("/") + lm_base_url = _get_provider_base_url("lmstudio") or "" lm_api_key = str(lm_cfg.get("api_key") or "").strip() if lm_base_url: headers = {"User-Agent": "OpenAI/Python 1.0"} diff --git a/tests/test_pr1970_lmstudio_base_url_fallback.py b/tests/test_pr1970_lmstudio_base_url_fallback.py new file mode 100644 index 00000000..a93d030c --- /dev/null +++ b/tests/test_pr1970_lmstudio_base_url_fallback.py @@ -0,0 +1,127 @@ +"""Regression for PR #1970 LM Studio provider × cfg.model.base_url shape. + +PR #1970 added `_get_provider_base_url()` + a dedicated lmstudio branch in +`get_available_models()` for fetching live loaded models via the OpenAI-compatible +/v1/models endpoint. + +The initial implementation only looked at `cfg["providers"]["lmstudio"]["base_url"]`, +missing the historical shape where users put `base_url` under `cfg["model"]` +(when `cfg["model"]["provider"] == "lmstudio"`). That shape is what +`tests/test_issue1527_lmstudio_base_url_classification.py` covers and what real +users have in their config.yaml — 3 pre-existing tests started failing on stage-337 +because of this gap. + +This regression test pins the helper's two-location lookup so a future change +can't accidentally drop the model.base_url fallback again. +""" +from __future__ import annotations + +import api.config as config + + +class _RestoreCfg: + """Context manager: snapshot cfg, restore on exit (test isolation).""" + + def __enter__(self): + import copy + self._snapshot = copy.deepcopy(config.cfg) + return self + + def __exit__(self, *exc): + config.cfg.clear() + config.cfg.update(self._snapshot) + + +def test_get_provider_base_url_finds_explicit_providers_entry(): + """When providers..base_url is set, return that value.""" + with _RestoreCfg(): + config.cfg.clear() + config.cfg.update({ + "providers": { + "lmstudio": {"base_url": "http://10.0.0.5:1234/v1", "api_key": "x"}, + }, + }) + assert config._get_provider_base_url("lmstudio") == "http://10.0.0.5:1234/v1" + + +def test_get_provider_base_url_strips_trailing_slash(): + with _RestoreCfg(): + config.cfg.clear() + config.cfg.update({ + "providers": { + "lmstudio": {"base_url": "http://10.0.0.5:1234/v1/", "api_key": "x"}, + }, + }) + assert config._get_provider_base_url("lmstudio") == "http://10.0.0.5:1234/v1" + + +def test_get_provider_base_url_falls_back_to_model_base_url(): + """When providers..base_url is unset but cfg.model.base_url is set + AND cfg.model.provider matches, the helper returns model.base_url.""" + with _RestoreCfg(): + config.cfg.clear() + config.cfg.update({ + "model": { + "provider": "lmstudio", + "base_url": "http://192.168.1.22:1234/v1", + "default": "qwen3.6-35b-a3b@q6_k", + }, + "providers": { + "lmstudio": {"api_key": "local-key"}, # no base_url here + }, + }) + # Was returning None before the fix — the regression that broke + # test_issue1527_lmstudio_base_url_classification. + assert config._get_provider_base_url("lmstudio") == "http://192.168.1.22:1234/v1" + + +def test_get_provider_base_url_returns_none_when_unconfigured(): + """Unconfigured provider returns None (sentinel for 'use SDK default').""" + with _RestoreCfg(): + config.cfg.clear() + config.cfg.update({"providers": {}}) + assert config._get_provider_base_url("openai") is None + assert config._get_provider_base_url("anthropic") is None + assert config._get_provider_base_url("lmstudio") is None + + +def test_get_provider_base_url_model_block_only_matches_active_provider(): + """cfg.model.base_url must NOT leak to providers other than cfg.model.provider. + + If model.provider is anthropic but providers.openai exists without base_url, + _get_provider_base_url("openai") must still return None — otherwise we'd + silently rewrite the OpenAI SDK target to an Anthropic endpoint URL. + """ + with _RestoreCfg(): + config.cfg.clear() + config.cfg.update({ + "model": { + "provider": "anthropic", + "base_url": "https://my-anthropic-proxy.example.com/v1", + }, + "providers": { + "openai": {"api_key": "ok"}, # no base_url + "anthropic": {"api_key": "ak"}, # no base_url + }, + }) + # Active provider gets the model.base_url fallback. + assert config._get_provider_base_url("anthropic") == "https://my-anthropic-proxy.example.com/v1" + # OpenAI must NOT inherit it. + assert config._get_provider_base_url("openai") is None + + +def test_get_provider_base_url_explicit_wins_over_model_fallback(): + """If both providers..base_url AND cfg.model.base_url are set with matching + provider, the explicit providers entry wins.""" + with _RestoreCfg(): + config.cfg.clear() + config.cfg.update({ + "model": { + "provider": "lmstudio", + "base_url": "http://wrong:1234/v1", + }, + "providers": { + "lmstudio": {"base_url": "http://correct:1234/v1", "api_key": "x"}, + }, + }) + assert config._get_provider_base_url("lmstudio") == "http://correct:1234/v1"