diff --git a/CHANGELOG.md b/CHANGELOG.md index 6115c51f..0c6a77dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- **PR #2135** by @franksong2702 — `/api/models/live?provider=custom:` now scopes custom-provider fallback models and direct `/v1/models` fetches to the requested named provider instead of leaking sibling `custom_providers` entries from the active profile. Bare `custom` only reads unnamed custom-provider entries, named custom providers also include `models` dict/list entries, and live fetch fallback uses the matched entry's `base_url` / `api_key` pair. + - **PR #2117** by @ayushere — `ctl.sh start` no longer crashes on macOS (bash 3.2) with `preserved[@]: unbound variable`. The dotenv-preserve loop in `_load_repo_dotenv_preserving_env()` iterated `"${preserved[@]}"` under `set -euo pipefail`, which bash 4+ silently allows on empty arrays but bash 3.2 (still the default `/usr/bin/bash` on macOS) treats as an unbound-variable error. Guards the iteration with `if [[ ${#preserved[@]} -gt 0 ]]; then ... fi` — matches the canonical bash 3.2 strict-mode pattern. This is the third bash 3.2 compat fix to land in `ctl.sh` (prior: `025f137f` guarded `CTL_BOOTSTRAP_ARGS[@]` with the `${arr[@]+...}` pass-through pattern, `630981a0` replaced `[[ -v ${key} ]]` with `[[ -n "${!key+x}" ]]`). Defense-in-depth: added `tests/test_ctl_bash32_compat.py` (5 static-pattern regressions) pinning both empty-array guards plus a denylist for bash 4+ syntax (`declare -A`, `mapfile`, `[[ -v ]]`, `${var^^}`, `${var,,}`) so the next regression surfaces in CI instead of a macOS user's terminal. Stage-343 reviewer added the regression-test file alongside the contributor's 5-LOC fix to ctl.sh. ## [v0.51.49] — 2026-05-12 — Release Y (stage-342 — 3-PR contributor batch — read-only worktree status endpoint + worktree-retained response preference + Codex quota credential-pool fallback) diff --git a/api/routes.py b/api/routes.py index efae299c..49442eca 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6104,27 +6104,85 @@ def _handle_live_models(handler, parsed): ids = [] if not ids: + custom_provider_entry = None + + def _custom_provider_entries_for_request(): + if not (provider == "custom" or provider.startswith("custom:")): + return [] + try: + from api.config import _custom_provider_slug_from_name + _cp_entries = cfg.get("custom_providers", []) + if not isinstance(_cp_entries, list): + return [] + _matches = [] + for _cp in _cp_entries: + if not isinstance(_cp, dict): + continue + _slug = _custom_provider_slug_from_name(_cp.get("name", "")) + if provider.startswith("custom:"): + if _slug == provider: + _matches.append(_cp) + elif provider == "custom" and not _slug: + _matches.append(_cp) + return _matches + except Exception: + return [] + + def _custom_provider_model_ids(_cp): + _ids = [] + + def _append(_mid): + _mid = str(_mid or "").strip() + if _mid and _mid not in _ids: + _ids.append(_mid) + + _append(_cp.get("model", "")) + _models = _cp.get("models") + if isinstance(_models, dict): + for _mid in _models: + if isinstance(_mid, str): + _append(_mid) + elif isinstance(_models, list): + for _item in _models: + if isinstance(_item, str): + _append(_item) + elif isinstance(_item, dict): + _append(_item.get("id") or _item.get("model") or _item.get("name")) + return _ids + + def _custom_provider_api_key(_cp): + _raw = _cp.get("api_key") + if _raw is not None: + _key = str(_raw).strip() + if _key.startswith("${") and _key.endswith("}") and len(_key) > 3: + _key = os.getenv(_key[2:-1], "").strip() + if _key: + return _key + _env = str(_cp.get("key_env") or "").strip() + return os.getenv(_env, "").strip() if _env else "" + # For 'custom' and 'custom:*' providers, provider_model_ids() # returns [] because they aren't real hermes_cli endpoints. # Fall back to the custom_providers entries from config.yaml so # the live-model enrichment step can add any models that weren't # already in the static list (issue #1619). if provider == "custom" or provider.startswith("custom:"): - try: - _cp_entries = cfg.get("custom_providers", []) - if isinstance(_cp_entries, list): - ids = [ - _cp.get("model", "") - for _cp in _cp_entries - if isinstance(_cp, dict) and _cp.get("model", "") - ] - except Exception: - pass + for _cp in _custom_provider_entries_for_request(): + if custom_provider_entry is None: + custom_provider_entry = _cp + ids.extend(_custom_provider_model_ids(_cp)) # If still no ids, try fetching from base_url directly (OpenAI-compat endpoint) if not ids and (provider == "custom" or provider.startswith("custom:")): - _base_url = cfg.get("model", {}).get("base_url") - _api_key = cfg.get("model", {}).get("api_key") + _base_url = None + _api_key = None + if custom_provider_entry: + _base_url = custom_provider_entry.get("base_url") + _api_key = _custom_provider_api_key(custom_provider_entry) + else: + _model_cfg = cfg.get("model", {}) + _base_url = _model_cfg.get("base_url") + _api_key = _model_cfg.get("api_key") if _base_url and _api_key: try: import urllib.request diff --git a/tests/test_byok_model_dropdown.py b/tests/test_byok_model_dropdown.py index 181d68f4..835d17c8 100644 --- a/tests/test_byok_model_dropdown.py +++ b/tests/test_byok_model_dropdown.py @@ -177,6 +177,32 @@ class TestLiveModelsCustomProviderFallback: """When provider='custom' and provider_model_ids() returns [], /api/models/live must fall back to custom_providers entries from config.yaml.""" + @staticmethod + def _install_provider_model_ids(monkeypatch, fn): + import types + + hermes_cli = types.ModuleType("hermes_cli") + hermes_cli.__path__ = [] + models = types.ModuleType("hermes_cli.models") + models.provider_model_ids = fn + monkeypatch.setitem(sys.modules, "hermes_cli", hermes_cli) + monkeypatch.setitem(sys.modules, "hermes_cli.models", models) + + @staticmethod + def _call_live_models(monkeypatch, cfg, provider): + import api.config as c + import api.routes as r + + r._clear_live_models_cache() + monkeypatch.setattr(c, "get_config", lambda: cfg) + monkeypatch.setattr(c, "_resolve_provider_alias", lambda p: p) + monkeypatch.setattr(r, "j", lambda _handler, payload, **_kw: payload) + TestLiveModelsCustomProviderFallback._install_provider_model_ids(monkeypatch, lambda _p: []) + + parsed = mock.MagicMock() + parsed.query = f"provider={provider}" + return r._handle_live_models(object(), parsed) + def test_custom_fallback_code_present(self): src = read("api/routes.py") m = re.search( @@ -241,6 +267,110 @@ class TestLiveModelsCustomProviderFallback: f"got {model_ids}" ) + def test_named_custom_fallback_returns_only_matching_provider_models(self, monkeypatch): + """custom: must not leak sibling custom_providers models.""" + cfg = { + "model": {"provider": "custom:infini-ai"}, + "custom_providers": [ + { + "name": "rightcode-codex", + "model": "gpt-5.5", + "models": {"gpt-5.5-mini": {}}, + "base_url": "https://right.codes/codex/v1", + }, + { + "name": "infini-ai", + "model": "glm-5.1", + "base_url": "https://open.bigmodel.cn/api/paas/v4", + }, + { + "name": "xiaomi-mimo", + "models": ["mimo-v2.5-pro"], + "base_url": "https://mimo.example.com/v1", + }, + ], + } + + resp = self._call_live_models(monkeypatch, cfg, "custom:rightcode-codex") + + assert resp["provider"] == "custom:rightcode-codex" + assert [m["id"] for m in resp["models"]] == ["gpt-5.5", "gpt-5.5-mini"] + + def test_bare_custom_fallback_ignores_named_custom_provider_models(self, monkeypatch): + """Bare custom only represents unnamed custom entries, not named siblings.""" + cfg = { + "model": {"provider": "custom"}, + "custom_providers": [ + {"name": "rightcode-codex", "model": "gpt-5.5"}, + {"name": "infini-ai", "model": "glm-5.1"}, + {"model": "unnamed-byok-model"}, + ], + } + + resp = self._call_live_models(monkeypatch, cfg, "custom") + + assert resp["provider"] == "custom" + assert [m["id"] for m in resp["models"]] == ["unnamed-byok-model"] + + def test_named_custom_live_fetch_uses_matching_entry_endpoint(self, monkeypatch): + """custom: live fetch must use that entry, not the active model config.""" + import json + import urllib.request + + requests = [] + + class Response: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps({"data": [{"id": "right-live-model"}]}).encode("utf-8") + + def fake_urlopen(req, timeout=None): + requests.append( + { + "url": req.full_url, + "authorization": req.headers.get("Authorization"), + "timeout": timeout, + } + ) + return Response() + + cfg = { + "model": { + "provider": "custom:infini-ai", + "base_url": "https://infini.example.com/v1", + "api_key": "infini-key", + }, + "custom_providers": [ + { + "name": "rightcode-codex", + "base_url": "https://right.codes/codex/v1", + "api_key": "right-key", + }, + { + "name": "infini-ai", + "base_url": "https://infini.example.com/v1", + "api_key": "infini-key", + }, + ], + } + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + resp = self._call_live_models(monkeypatch, cfg, "custom:rightcode-codex") + + assert requests == [ + { + "url": "https://right.codes/codex/v1/models", + "authorization": "Bearer right-key", + "timeout": 8, + } + ] + assert [m["id"] for m in resp["models"]] == ["right-live-model"] + # ── Regression: known-good providers still work ───────────────────────────────