Fix custom live model scoping

This commit is contained in:
Frank Song committed 2026-05-12 20:05:28 +08:00
1 parent 15d620392f
commit b7c5ba640c
3 files changed
+202 -12

No files matched your search

+2
View File
@@ -4,6 +4,8 @@
### Fixed
- **PR #2135** by @franksong2702 — `/api/models/live?provider=custom:<slug>` 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)
+70 -12
View File
@@ -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
+130
View File
@@ -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:<slug> 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:<slug> 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 ───────────────────────────────