Fix custom live model scoping
This commit is contained in:
1 parent
15d620392f
commit
b7c5ba640c
3 files changed
+202
-12
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ───────────────────────────────
|
||||
|
||||
|
||||
Reference in new issue
Block a user